Router v2 β Parity Spec & Conventions
Design doc for the next iteration of vprs's file-based router. The v1 foundation (dynamic params, typed nav, edge/static/dev resolution) is in place; v2 makes the router on par with the alternatives (TanStack Router/Start, Next.js app router, Remix) and fixes the mixed-concern config surface v1 exposed.
North star: the user declares routes once, and dev / static / edge / prefetch all derive from that single table. Minimize what the user has to think about.
1. What the foundation already has
- Dynamic
$paramsegments and a$catch-all, with specificity ordering (literal > param > catch-all). - Params typed from the pattern literal (
RouteParams<"/profile/$id">β{ id: string }) β no codegen. Typed<Link to>/navigatevia aRegistermerge. - Loaders that receive
{ params, request }, so an auth route gates by reading the request. Works on dev, static (SSG) and edge. - Client nav:
<Link>,navigate,useParams/useLocation/useRouter, prefetch-on-intent, per-url flight cache. - SSR + streaming, flash-free hydration; a 404 route;
getStaticPathsenumeration for prerendering concrete dynamic urls.
That is already most of a router. The gaps below are what "on par" requires.
2. Parity matrix
Legend: β have Β· β v2 target Β· β n/a.
| Capability | TanStack | Next (app) | Remix | vprs now | vprs v2 |
|---|---|---|---|---|---|
| Dynamic params / catch-all | β | β | β | β | β |
| Typed routes / params / links | β | partial | partial | β | β |
| Data loader (params + request) | β | β | β | β | β |
| Client nav + prefetch | β | β | β | β | β |
| SSR / streaming / RSC | β | β | β | β | β |
Nested layouts / <Outlet/> | β | β | β | β | β |
| Per-layout (nested) loaders | β | β | β | β | β |
| Index routes | β | β | β | β | β |
| Pathless / grouping layouts | β | β | β | β | β |
| Per-route error boundary | β | β | β | β | β |
| Per-route pending / loading UI | β | β | β | β | β |
| Per-route head / meta | β | β | β | partial | β |
| Redirect / notFound from a loader | β | β | β | β | β |
| Search-param parsing/validation | β | β | partial | β | (later) |
Route context / beforeLoad middleware | β | partial | partial | via loader | (later) |
The core new work is nested layouts (and everything that composes with them: nested loaders, error/loading boundaries, index/pathless routes). The rest are small, well-scoped additions.
3. File conventions
Modeled on TanStack Start / Next app router / Remix, mapped onto vprs's existing
shell (Html β Root β page). Scan root is routes.dir, resolved relative to
moduleBase (so moduleBase: "src" + routes: { dir: "page" } scans src/page).
src/page/
__root.tsx # the app shell (renders <Outlet/>); becomes vprs's Root
route.tsx # a LAYOUT for this segment + its children (renders <Outlet/>)
page.tsx # a LEAF route's UI [have]
props.ts # a route/layout loader: props(url, {params,request}) [have]
index.tsx # the exact-path leaf for a layout segment
error.tsx # error boundary for this segment + children
loading.tsx # Suspense fallback for this segment + children
head.ts # head/meta contribution for this segment
$id/β¦ # dynamic param segment [have]
$/β¦ # catch-all segment [have]
(group)/β¦ # pathless group: shared layout, no URL segment
Mapping to vprs today:
| convention | vprs concept |
|---|---|
__root.tsx | the Root shell component (renders <Outlet/>); Html stays the <html> document wrapper |
page.tsx / index.tsx | leaf page component (have) |
props.ts | loader (have) β now also valid beside a route.tsx layout |
route.tsx | new: a layout server component that renders {children} |
error.tsx / loading.tsx | new: client ErrorBoundary / Suspense boundary wrapping the segment |
head.ts | new: head fragment merged into the document <head> |
The page/props filename patterns come from autoDiscover.pagePattern /
propsPattern (single source of truth) instead of scanRoutes re-hardcoding
page.tsx / props.ts.
4. How it maps to RSC (implementation notes)
Nested layouts. scanRoutes becomes a tree (parent/child), not a flat
pattern list. Resolving a matched url yields the chain from __root down
through each route.tsx layout to the leaf page.tsx:
<RootLayout> // __root.tsx
<SectionLayout> // page/dashboard/route.tsx
<SettingsPage/> // page/dashboard/settings/page.tsx
</SectionLayout>
</RootLayout>
Natural in RSC: each layout is a server component that renders {children}
(<Outlet/> is just children), and the flight streams the whole nested tree.
Each layout may have its own props.ts; the loaders for a matched chain resolve
in parallel and each layer gets its { params, request }. Touches
resolvePageAndProps (resolve a chain + per-layer props) and
createElementWithReact (compose the chain instead of a single Page).
Error boundaries (error.tsx). A client component ("use client") rendered
as an ErrorBoundary wrapping its segment's subtree in the composed chain. vprs
already streams; this is a wrapper insertion at compose time.
Loading / pending (loading.tsx). A <Suspense fallback={Loading}>
wrapping the segment, so a slow nested loader streams its fallback first.
Head / meta (head.ts). Each matched segment may contribute head tags;
merged and rendered in the document <head> via the Html wrapper (react-dom's
precedence-hoisted <title>/<meta>, same mechanism as the CSS <Css> path).
Redirect / notFound from a loader. The loader signals a redirect/notFound (thrown sentinel or returned marker). The request handler translates it: on a per-request render (dev/edge) β a 3xx / the 404 route; at prerender β skip the page (or emit the 404 shell). Consumers still must not key urls on mutable data.
Boundary of the render change. All of this is compose-time β building a nested element chain with wrappers β over machinery vprs already has (streaming flight, client references, Suspense). No new transport or worker model.
Shell layering β unopinionated, both supported. The persistent chrome and the nested layouts are orthogonal, so an app uses either or both:
- Server
route.tsxlayouts live inside the flight β server-rendered, data-driven, and swapped on navigation. - The client
wrap(startClient({ wrap })) lives outside the flight β providers / app frame that must persist across navigation and hold client state. Required when the chrome can't render under thereact-servercondition (the dashboard's Chakra providers, vprs bug 2dd).
Navigation swaps the flight content inside the stable wrap. Layering:
Html (document) β client wrap (persistent chrome) β Root (#root) β route.tsx
layouts (swappable, data-driven) β leaf page. Every layer is opt-in β a bare
app is just Html β Root β page; a static site (mmc) leans on layout-loaders +
merged head; a client-heavy app (dashboard) leans on the wrap + file routes.
Optimization, later: shared layouts persisting across navigation (partial rendering) rather than re-fetching the full flight. Correct-but-simple first; optimize once the tree lands.
5. Config surface (the routes field)
The v1 API made the user hand-copy Page / props / routePatterns /
build.pages into the plugin config. v2 collapses that into one field:
vitePluginReactServer({
moduleBase: "src",
routes: { dir: "page" }, // the route table; the plugin fans it out
build: { edge: { minify: false } }, // build.pages defaults from the router
})
routesaccepts a file-router config ({ dir, staticPaths }, the default and only mode today) or a custom router object ({ Page, props, routePatterns, getStaticPaths }) β a hand-rolled router is just that object.resolveOptionsis the fan-out point: it prependsmoduleBasetoroutes.dirand projects the table into the existingPage/props/routePatterns/build.pages, and threads it to dev / static / edge.build.pagesis demoted to the prerender worklist (an output concern, not a routing one). It defaults torouter static-routes βͺ getStaticPaths; its function form receives the router-derived list so you can filter/extend/replace without restating routes:build: { pages: (routerPages) => [...routerPages, "/extra"] }.- Top-level
Page/props/routePatternsremain as a low-level escape hatch (now expressed as a customroutesobject) with a soft-deprecation nudge towardroutes.routePatternsis unpublished, so it just moves.
Naming: routes (the route table) is preferred over router for the config
field, so router keeps meaning the client-nav runtime in ./router/client.
6. Non-goals (for v2)
- Search-param schema/validation (TanStack's typed search) β useful but a separate surface; revisit after the tree lands.
- Route context /
beforeLoadmiddleware β the loader-reads-request pattern covers auth today; a dedicated middleware layer is a later addition. - Mutation/action routing β vprs has server actions as their own mechanism.
- Keying urls on mutable data (existing design boundary β unchanged).
7. Sequencing
routesfield +resolveOptionsfan-out β one field,moduleBasecomposition, projects into the existing pipeline. (Ships the current flat capability behind the final API.)- Unify the page/props namespace β
scanRoutesreadsautoDiscoverpatterns; kill the duplicate definition. build.pages(routerPages)transform β output-side worklist override, resolved once inresolveOptions.- Nested layouts +
<Outlet/>β the tree scan + chain composition + nested loaders. The core parity gap. - Parity extras β index routes, pathless groups,
error.tsx/loading.tsx,head.ts, loader redirect/notFound. - Migrate + deprecate β example / mmc / bidoof / docs onto
routes; soft deprecation of the loosePage/props/routePatterns.
Because a tree with no intermediate layouts is the flat case, the routes
field can be designed tree-shaped from step 1, and layouts (step 4) land
additively with no API churn.
Folded in along the way: the three low-priority review follow-ups (the
.html-vs-param normalize ambiguity, the unbounded client flight cache, the
per-request routePatterns array that defeats the matcher's sort cache) get
fixed as their surrounding code is reworked.
8. Resolved decisions (provisional β revisit if they bite)
routes.dirβ optional, defaults tomoduleBaseroot.routes: {}scansmoduleBaseitself; where the routes live is expressed bymoduleBase(an app can point it atapp, matching theapp/-at-root convention). Setdironly when routes are a subfolder of a larger source root β mmc keepsmoduleBase: "src"androutes: { dir: "app" }becausesrcalso holdsdata/config/components. So the default is Next-like (appat the base), anddiris the escape hatch for a mixedsrc.- Head/meta β
head.tsexport. Ahead.tsbeside the route exportstitle/meta, receiving{ params, data }so meta can derive from loader data, and merging up the matched chain. Matches the loader model and fits RSC (meta usually comes from data, not markup). A<Head>component can coexist later, but the export is the primary convention. error.tsxβ per-segment + root default.error.tsxat any segment wraps that subtree; a root-levelerror.tsxcatches anything otherwise uncaught (full Next/Remix parity). Falls through to vprs's existing error handling if no boundary matches.
9. Shipped: nested layouts (route.tsx)
Nested layouts + per-layer loaders (parity-matrix section 2, sequencing step 4)
are implemented. A route.tsx beside/above a page.tsx is a server layout that
renders {children} (the RSC-native <Outlet/>); scanRoutes builds the
rootβleaf chain, resolvePageAndProps resolves each layer's component + loader
props (resolveLayoutChain), and createElementWithReact folds
<L0 {...p0}><L1 {...p1}><Page/>β¦ so the whole tree streams as one flight.
Threading mirrors Page/props: fileRouter().layouts(url) β resolveOptions
(layoutsResolver) β getRouteFiles β the render pipeline. Works on static
prerender, dev (both the react-server main-thread render and the client-dev
RSC worker), and the baked edge bundle. Client navigation is free β the
server flight already carries the nested tree, so the client renders it with no
extra work.
Conventions as-built:
- Layout export =
Layout(configurable vialayoutExportName). Distinct fromPage(leaf) andRoot(the#rootshell) β a segment may have both aLayoutand aPage, and they share that segment'sprops.ts. - Layout modules are build inputs. Every matched
route.tsx(+ its sharedprops.ts) is added to the server build so the renderer can load it; an unbuilt/malformed layer is skipped (the page still renders) rather than failing the route. requestreaches layout loaders in every mode, including client-dev (RSC in the worker): aRequestcan't be structured-cloned across the worker boundary, so the dev handler sends a serialized stand-in (absolute url + method + headers) that the worker rebuilds into aRequestfor the loader ctx. So an authenticated layout can gate on cookies/headers the same way a page loader does.head.ts,error.tsx,loading.tsx, index/pathless routes, and loader redirect/notFound remain (sequencing step 5).