Nested Layouts

Layouts share UI and loader data across a filesystem branch. root.tsx owns the document; each _route.tsx wraps the pages and deeper layouts in its directory.

Root Document

Every pages directory requires root.tsx:

src/pages/root.tsx
import { defineRootRoute, HeadContent, Scripts } from "@teyik0/furin";
import "./globals.css";

export const route = defineRootRoute()
  .config({ mode: "ssg" })
  .layout(({ children }) => (
    <html lang="en">
      <head>
        <HeadContent />
      </head>
      <body>
        {children}
        <Scripts />
      </body>
    </html>
  ));

Furin does not add a second document shell. Omitting <HeadContent /> or <Scripts /> makes the root invalid because metadata, serialized data, framework clients, and the hydration entry need those insertion points.

Directory Layouts

src/pages/dashboard/_route.tsx
import { defineRoute } from "@teyik0/furin";

export const route = defineRoute("/dashboard/_route")
  .config({ mode: "ssr" })
  .loader(async ({ request }) => ({ user: await getSession(request) }))
  .layout(({ children, user }) => (
    <div className="dashboard-shell">
      <DashboardNav user={user} />
      <main>{children}</main>
    </div>
  ));

A page in that directory gets the layout's loader fields through its route id:

src/pages/dashboard/settings.tsx
import { defineRoute } from "@teyik0/furin";

export const route = defineRoute("/dashboard/settings")
  .config({ mode: "ssr" })
  .loader(async ({ user }) => {
    const sessionUser = await user;
    return { settings: await getSettings(sessionUser.id) };
  })
  .page(({ settings, user }) => <Settings user={user} value={settings} />);

Ancestor loader fields are exposed directly as props on descendant components. In descendant loaders, each ancestor field is an individual promise, so unrelated work can start in parallel and a child only awaits what it needs.

A layout receives its own loader fields, its ancestors' fields, params, path and query. It never receives a descendant's fields, so its props are the same whether the page renders, fails or is not found. When a page returns a field with the same name as a layout field, the page sees its own value and the layout keeps its own. See Error Handling for which layouts stay rendered around a failure.

Multiple Levels

text
src/pages/
├── root.tsx
└── dashboard/
    ├── _route.tsx
    └── account/
        ├── _route.tsx
        └── security.tsx

security.tsx is wrapped by account/_route.tsx, itself wrapped by dashboard/_route.tsx and root.tsx. The filesystem is the only source of that chain: a route never imports its layout, and its id gives TypeScript the same parent from the generated route types.

A layout can't be reused outside its directory. Extract ordinary React components for visual reuse and keep route layouts aligned with URL ownership.

Rendering Modes Across A Chain

Each segment keeps its mode. An SSR layout can read request data above an SSG or ISR page: its loader runs per request, the public child's loader stays cached, and the document is private, no-store. The public child loader cannot read the layout's private fields. For a cached HTML shell, move private work to requestLoader() and <Suspense>; see Rendering Modes. SSR child loaders and components can inherit layout requestLoader() fields as promises, but SSR pages cannot declare their own requestLoader().

Deferred fields also flow through layouts. Render them with <Await> inside <Suspense> exactly as you would in a page; see Deferred Data.

Page State and Navigation

Furin gives each page a React identity based on its route pattern and validated path parameters. Navigating from one entity to another, such as /cards/one to /cards/two, remounts the page and resets its local state, including mutation errors. Shared layouts stay mounted, and the data cache is not cleared.

Refreshing loader data, applying sync updates, or changing only search parameters or the URL hash preserves the current page's state. A remount does not cancel an already-running network request, and leaving a route does not store its local state for a later visit.

Use remountDeps in config() to choose which values identify a page:

Config spreads can provide remountDeps through local object literals. For an imported or dynamically computed config, declare remountDeps explicitly after the spread so the client transform can preserve it without including server configuration.

src/pages/documents/[documentId]/[tab].tsx
export const route = defineRoute("/documents/[documentId]/[tab]")
  .config({
    mode: "ssr",
    params: t.Object({ documentId: t.String(), tab: t.String() }),
    remountDeps: ({ params }) => [params.documentId],
  })
  .page(({ params }) => <Editor documentId={params.documentId} tab={params.tab} />);

This page remounts when documentId changes, but keeps its state when tab changes. Return [] to preserve state across all parameter changes while the same route remains active:

tsx
remountDeps: () => []

The callback receives only params and query, inferred from the route's schemas. It must be synchronous and deterministic, and return a readonly array of primitives: strings, numbers, booleans, null, or undefined. Dependencies are compared by value, not by the array's reference. Search values affect page identity only when explicitly included in this array; loader data never participates.

Declare the callback directly in the inline config() object. It runs on both the server and the client, so its helpers must be browser-safe. Furin retains only this callback from the configuration in the client bundle; schemas and other server options are removed. This option controls pages, not layout lifetimes.

Previously, changing path parameters could preserve a page's local state. Use remountDeps: () => [] if a route intentionally relies on that behavior.

Comments