Server-side Content Protection
When you run Dev Portal in SSR mode, protectedRoutes is
enforced beyond the runtime login dialog. The JavaScript chunks containing content for protected
routes are physically separated from the public bundle and served only through an auth-gated
endpoint. Unauthenticated users cannot fetch them even if they know the URL.
Why this exists
In a typical SPA build, every page's JavaScript is code-split into a chunk in /assets/. Any
browser can fetch any chunk URL. A runtime RouteGuard can block rendering a protected page, but
the code itself is still downloadable.
In SSR mode, the build additionally:
- Classifies each code-split chunk as public or protected based on which routes it serves.
- Moves protected chunks from the public output into the server bundle, so they're no longer served as plain static files.
- Registers an auth-gated route at
/_protected/*on the SSR adapter that requires a valid session cookie.
A request to a protected chunk URL without a session returns 401 Unauthorized. Combined with
RouteGuard on render, protected content stays on the server.
How classification works
At build time, a Vite transform AST-scans your code for route-shaped dynamic imports and records
{moduleId → subtree root} entries in a registry. Two shapes are auto-detected.
Shape A: object literal with path
Any object literal with a string path property. Every dynamic import() inside the object's other
property values is registered as subtree-scoped at that path.
Code
Shape B: dict keyed by route path
An object whose keys are route-path strings (start with /, contain no .) mapping to arrow
functions that call import().
Code
The dot guard keeps file-path dicts (like {"/abs/path/x.js": ...}) from being mistaken for route
dicts.
From registry to chunking
- The annotator transform scans every first-party module and populates the registry.
- Rolldown's
manualChunkscallback consults the registry for each module. If any registered subtree for that module intersects aprotectedRoutespattern, the module goes into aprotected-*chunk. - After bundling, protected chunks are renamed into a
_protected/directory and moved from the client output to the server output. - A static-reachability assertion fails the build if any public chunk statically imports a protected chunk (which would eagerly pull gated code into the public bundle).
What's covered out of the box
| Content source | Shape | Auto-detected? |
|---|---|---|
MDX docs (plugin-docs) | Shape B (route dict) | ✅ |
File OpenAPI (plugin-api) | Shape A (via openApiPlugin) | ✅ |
User custom pages with lazy | Shape A ({path, lazy}) | ✅ |
User custom pages with element | Not code-split | ❌ (see below) |
URL-based OpenAPI (type: url) | Fetched at runtime | ❌ (see below) |
Raw inline OpenAPI (type: raw) | Inlined in main bundle | ❌ (see below) |
Caveats
Dynamic route paths
The annotator only recognizes string literals. Configs that generate routes with computed paths are not detected:
Code
Fix: nest the dynamic entries under a static-path ancestor so the outer Shape A match catches them:
Code
The outer {path: "/foo", ...} registers every nested dynamic import as subtree-scoped at /foo,
so protectedRoutes: ["/foo/*"] covers them all. Alternatively, write the entries out with literal
paths.
Inline JSX custom pages
Writing
Code
ships <Secret /> directly in the main bundle. There's no chunk to gate and no URL to block; the
runtime RouteGuard prevents rendering but the JavaScript is already on the user's machine.
Fix: switch to lazy:
Code
URL-based OpenAPI specs
{ type: "url", input: "https://example.com/api.yaml" } fetches at runtime from whatever origin you
configure. Auth is your responsibility on that origin. Dev Portal cannot gate a URL it does not serve.
Raw inline OpenAPI specs
{ type: "raw", input: {...} } embeds the spec as a JS object literal in the bundle. Same situation
as inline custom pages: no chunk, no way to gate at the bundle level.
Third-party and custom plugins
If a plugin emits code-split routes in neither Shape A nor Shape B, its chunks aren't detected. Two options:
- Have the plugin emit a detectable shape. Usually the easiest: wrap the generated routes in an
object with a string
path. - Register directly. Plugins can call
registerProtectedScope(moduleId, {type: "subtree", root: "/your-path"})from their Viteloadhook.
The build-time check
If a protectedRoutes pattern has no registered content, the build fails:
Code
Three things to check:
- Typo. Does the pattern match any real route?
- Dynamic content. Computed paths? Apply the nested-subtree fix above.
- Inline content. Is the route served by an inline JSX element or a raw spec? It cannot be gated at the bundle level; move the content into a code-split module.
If none of those apply and you're sure the content should be detected, file an issue with a minimal reproduction.
Dev mode and SSG
Dev mode doesn't chunk-split the same way as production, so the bundle-level gating is absent.
Only the runtime RouteGuard applies. Use a production SSR build to verify gating.
SSG builds have no server. protectedRoutes in SSG falls back to client-side enforcement only:
RouteGuard blocks rendering, but chunks remain publicly fetchable. If content must stay
server-side, use an SSR adapter.
Pre-ship checklist
- Build passes (any unmatched
protectedRoutespattern fails the build). - Any custom pages meant to be protected use
lazy: () => import(...), notelement. - Any dynamically-generated protected routes are nested under a static-path ancestor.
- URL-based and raw inline OpenAPI specs have their own access control at their origin.
- Visit a protected chunk URL directly in an unauthenticated browser (grab one from DevTools)
and confirm you get
401 Unauthorized.
Related
- Protected Routes: the
protectedRoutesconfig API. - Authentication: wiring up an auth provider so sessions exist.