Nested routes
NestedRouter renders a parent/child route tree with React-Router-v6-style Outlet slots. Each layer picks up its own params; unmatched tails render the nearest noMatch. Use it inside a remote when its own UI needs layout composition — header/sidebar/main shells, tab containers, master-detail views.
Nested vs. file-based routing
NestedRouter is a declarative tree (you write JSX). The file-based jorvel.routes.ts generated by jorvel routes is a flat list. Use nested routing when you need shared layouts; use file-based routing for catalog pages. They compose — drop a RemoteApp inside an outlet, or a NestedRouter inside a page.Define the tree
import { NestedRouter, Outlet, type NestedRoute } from '@jorvel/runtime';
const routes: NestedRoute[] = [
{
path: '/app',
element: <AppShell />,
children: [
{ index: true, element: <Dashboard /> },
{
path: 'users',
element: <UsersLayout />,
children: [
{ index: true, lazy: () => import('./pages/users/index.js') },
{ path: ':id', lazy: () => import('./pages/users/detail.js') },
],
},
{ path: 'settings/*', lazy: () => import('./pages/settings.js') },
],
},
];
export default function Root() {
return <NestedRouter routes={routes} fallback={<Spinner />} notFound={<NotFound />} />;
}
function AppShell() {
return (
<div className="app-shell">
<Sidebar />
<main><Outlet /></main>
</div>
);
}Index routes
Mark a child with index: trueto render when the parent path matches exactly. Only one index per children array. Index routes do not contribute a URL segment — they decide what shows up at the parent's URL.
Lazy layouts
Any route can ship lazy: () => import(...) instead of element. The module default export becomes the layout/page. React Suspense shows fallback while the chunk loads. Use lazy for everything except the smallest cross-cutting shells (sidebar, top nav) so the initial bundle stays minimal.
Params from ancestors
useOutletParams reads the cumulative params from every ancestor segment, not just the deepest. Nested :userId + :postId are both available in the deepest component.
import { useOutletParams } from '@jorvel/runtime';
function UserDetail() {
const params = useOutletParams<{ id: string }>();
return <h1>User {params.id}</h1>;
}Route shape
| Key | Type | Purpose |
|---|---|---|
path | string | Pattern with :param / * splat. Required unless index: true. |
index | boolean | Match when the parent path is hit exactly. Mutually exclusive with path. |
element | ReactNode | Eager render. Use for cheap shells (sidebar, header). |
lazy | () => Promise<{ default: ComponentType }> | Code-split chunk. Suspense fallback = parent NestedRouter fallback. |
children | NestedRoute[] | Sub-routes rendered into <Outlet />. |
guards | RouteGuard[] | Run before render. Falsy blocks; { redirect } redirects. |
loading | ReactNode | Per-segment Suspense fallback — the loading.tsx convention. Shown while this segment's lazy chunk (or a Suspense-throwing descendant) resolves. |
errorElement | ReactNode | (p => ReactNode) | Per-segment error boundary — the error.tsx convention. A bare node, or a render fn receiving { error, reset }. |
handle | unknown | Arbitrary metadata — e.g. breadcrumb labels, tab IDs, analytics tags. |
Per-segment loading.tsx / error.tsx
Each segment owns its own loading and error boundaries — the same contract as the Next.js App Router's co-located loading.tsx / error.tsx, expressed as the loading and errorElement fields. A pending or crashing child is contained at its segment: the parent layout (header, sidebar) keeps rendering while only the affected slot swaps to its fallback.
const routes: NestedRoute[] = [
{
path: '/dashboard',
element: <DashboardShell />, // stays mounted through child loads + crashes
errorElement: <DashboardError />, // catches anything below with no closer boundary
children: [
{ index: true, lazy: () => import('./Overview.js') },
{
path: 'reports/*',
lazy: () => import('./reports/Router.js'),
loading: <ReportsSkeleton />, // Suspense fallback scoped to this segment
errorElement: ({ error, reset }) => ( // render-fn form gets error + reset
<ReportsError message={String(error)} onRetry={reset} />
),
},
],
},
];loading shows while the segment's lazy chunk resolves and while any descendant throws a promise (e.g. useRemoteData / use()). errorElement catches render-time throws — including a failed lazyimport — and only that subtree is replaced. The render-fn form receives reset() to retry without a full reload.
Route metadata via handle
Walk the matched route chain to build breadcrumbs, sidebars, analytics tags. Each route's handle field is opaque — define your own convention.
const routes: NestedRoute[] = [
{
path: '/billing',
element: <BillingShell />,
handle: { breadcrumb: 'Billing' },
children: [
{ path: 'invoices/:id', element: <Invoice />, handle: { breadcrumb: 'Invoice' } },
],
},
];
import { useMatchedRoutes } from '@jorvel/runtime';
function Breadcrumbs() {
const matched = useMatchedRoutes<{ breadcrumb: string }>();
return (
<nav>
{matched.map((m, i) => <span key={i}>{m.handle.breadcrumb}</span>)}
</nav>
);
}Resolving outside React
Need to know what would render at a path without mounting it? Use resolveChain — useful for prefetching ancestor chunks or server-side authorization checks.
import { resolveChain } from '@jorvel/runtime';
const chain = resolveChain(routes, '/app/users/42');
// → [
// { route: routes[0], params: {} },
// { route: routes[0].children[1], params: {} },
// { route: routes[0].children[1].children[1], params: { id: '42' } },
// ]
//
// chain.at(-1).params.id === '42'Interop with federated remotes
Put RemoteOutlet inside a layout element. The nested router owns the chrome, the remote owns a subtree. Both coexist because they read the same usePathname stream.
const routes: NestedRoute[] = [
{
path: '/app',
element: <AppShell />,
children: [
{ index: true, element: <Dashboard /> },
{ path: 'admin/*', element: <RemoteOutlet routes={ADMIN_ROUTES} remotes={REMOTES} /> },
{ path: 'docs/*', lazy: () => import('./docs/Router.js') },
],
},
];SSR with nested routes
On the server, resolveChain + a sync renderer cover the same shape without relying on browser history. Pair with renderRouteToString from @jorvel/ssr:
import { resolveChain } from '@jorvel/runtime';
import { renderRouteToString } from '@jorvel/ssr';
const chain = resolveChain(routes, new URL(request.url).pathname);
if (!chain.length) return new Response('Not Found', { status: 404 });
const html = await renderRouteToString(<NestedRouter routes={routes} />, {
path: new URL(request.url).pathname,
});Don't mount NestedRouter twice on one URL
pushState. Pick one as the owner per URL prefix.