JORVEL

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

tsx
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.

tsx
import { useOutletParams } from '@jorvel/runtime';

function UserDetail() {
  const params = useOutletParams<{ id: string }>();
  return <h1>User {params.id}</h1>;
}

Route shape

KeyTypePurpose
pathstringPattern with :param / * splat. Required unless index: true.
indexbooleanMatch when the parent path is hit exactly. Mutually exclusive with path.
elementReactNodeEager render. Use for cheap shells (sidebar, header).
lazy() => Promise<{ default: ComponentType }>Code-split chunk. Suspense fallback = parent NestedRouter fallback.
childrenNestedRoute[]Sub-routes rendered into <Outlet />.
guardsRouteGuard[]Run before render. Falsy blocks; { redirect } redirects.
loadingReactNodePer-segment Suspense fallback — the loading.tsx convention. Shown while this segment's lazy chunk (or a Suspense-throwing descendant) resolves.
errorElementReactNode | (p => ReactNode)Per-segment error boundary — the error.tsx convention. A bare node, or a render fn receiving { error, reset }.
handleunknownArbitrary 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.

tsx
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.

tsx
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.

ts
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.

tsx
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:

ts
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

Two routers writing to the same pathname is harmless (both just read the History API), but two routers navigating compete for the same pushState. Pick one as the owner per URL prefix.