Nested & Layout Routing

Real applications are not flat lists of pages. A dashboard has a sidebar that stays put while the panel beside it changes; a settings area shares one header across a dozen sub-screens; a product page keeps its gallery mounted while tabs swap underneath. Nested routing is the technique that expresses that structure in the route table itself, so the router — not ad-hoc conditionals scattered through components — decides which parts of the UI persist and which parts are replaced on each navigation.

← Back to Routing Architecture & Fundamentals

The Problem

Without nested routing, every route owns its entire screen. A flat route table maps /settings/profile to one component and /settings/billing to another, and each of those components renders the settings header, the settings sidebar, and the settings breadcrumb itself. The duplication is the least of the damage. Because both components are siblings rather than children of a common parent, moving between them unmounts the whole subtree and mounts a fresh one: the sidebar re-renders, its scroll position resets, any open disclosure collapses, and any data the header had already fetched is fetched again.

The symptoms show up as bugs that look unrelated. A navigation menu flickers on every route change. A search box inside a persistent header loses focus mid-keystroke. A collapsible tree in a file browser resets its expanded nodes each time the user opens a different file. An analytics header refetches the same summary counts on every tab. Teams usually patch these one at a time — lifting state into a global store, memoising the sidebar, caching the fetch — when the real fix is structural: the sidebar should never have unmounted, because it belongs to a route above the one that changed.

There is a second, quieter cost. Because the flat model gives each route the whole screen, the route table stops describing the application’s shape. Reading it tells you which URLs exist but nothing about which screens belong together, which chrome they share, or where a permission check should sit. New contributors infer the structure from component imports instead, and the inference is usually wrong in exactly the places it matters — a guard added to four of the five screens in an area, a breadcrumb that is correct on every screen except the one added last. A nested table encodes that structure once, in the place everyone already looks when they add a page.

Nested routing solves this by making the route table a tree that mirrors the UI tree. /settings is a route that renders the settings layout and declares a hole in itself; /settings/profile and /settings/billing are children that fill the hole. Navigating between the two children re-renders only the hole. The layout above it is untouched — same component instance, same state, same DOM nodes, same scroll offset. State preservation stops being something you engineer and becomes a consequence of where a route sits in the tree.

Flat routes remount everything; nested routes remount only the leaf Side-by-side comparison. With a flat route table, navigating from profile to billing replaces the entire screen including the shared header and sidebar. With a nested route table, the shared layout stays mounted and only the inner panel is replaced. Flat routes — everything remounts /settings/profile header + sidebar profile panel /settings/billing header + sidebar billing panel dashed = torn down and rebuilt Nested routes — only the hole changes /settings — layout stays mounted header + sidebar — same instance, state intact profile panel billing panel only the outlet is replaced Consequences of the flat model Sidebar scroll resets · focus is lost · shared data refetches · transitions flicker Each is usually patched separately, when the real fix is to move the shared UI one level up the route tree.
A flat route table makes every navigation a full-screen replacement; a nested table confines the change to the deepest segment that actually differs.

Core API & Primitives

Every nested router, regardless of framework, is built from the same four ideas.

A route tree. Routes have children, and a child’s path is interpreted relative to its parent. A parent path of settings with a child path of profile matches /settings/profile. The tree is the source of truth for both matching and rendering.

// TypeScript 5.x — a framework-agnostic route tree definition

interface RouteDefinition {
  /** Path segment, relative to the parent. "" marks the index route. */
  path: string;
  /** Component or render function for this level of the tree. */
  render: (ctx: RouteContext) => Node;
  /** Optional per-level data fetch, run in parallel with siblings' ancestors. */
  load?: (ctx: RouteContext) => Promise<unknown>;
  children?: RouteDefinition[];
}

interface RouteContext {
  params: Record<string, string>;
  /** Renders the matched child, if any. Called by the parent's render(). */
  outlet: () => Node | null;
  data: Record<string, unknown>;
}

A match chain, not a single match. Matching /settings/billing/invoices against a nested table does not return one route; it returns an ordered chain — settingsbillinginvoices. Each entry carries its own params and its own loader. This is the structural difference from the flat matchers described in route matching algorithms, which return a single winner.

An outlet. A parent renders its own chrome and calls outlet() wherever the child belongs. React Router spells this <Outlet />, Vue Router <router-view>, Angular <router-outlet>, and SvelteKit passes children implicitly through +layout.svelte. The name changes; the contract does not — see nested route outlets explained for the mechanics.

An index route. When the URL stops at the parent — /settings with nothing after it — something still has to fill the outlet. That is the index route, declared with an empty path. Getting the distinction between an index route and a layout route right is the single most common source of “why is my page blank” confusion; index routes vs layout routes works through it.

These four combine into a rule worth stating plainly: the URL determines which levels change, and the tree determines which levels persist. If two screens share chrome, that chrome belongs to a route above both of them. If two screens share nothing, they should not share an ancestor beyond the root. Most nesting mistakes are a mismatch between those two sentences — a layout placed too high, so it wraps screens that have nothing in common, or placed too low, so identical chrome is duplicated across siblings.

A fifth, optional primitive is the pathless layout route — a route with a render but no path. It contributes a level of UI without contributing a URL segment, which lets you wrap three routes in a shared frame without inventing a /frame prefix nobody wanted in the address bar.

Step-by-Step Implementation

The router below matches a nested table, resolves the chain, and renders from the leaf back up to the root. It is deliberately small — under a hundred lines — because the point is that nesting is a matching-and-composition concern, not a framework feature.

Prerequisite: a working single-level router that reacts to pushState and popstate. Nesting replaces its matcher and its render step, nothing else.

1. Match the URL to a chain of routes

Walk the tree segment by segment, collecting every route that consumes part of the path. Dynamic segments contribute to params as they are consumed, exactly as they do for a flat matcher.

// TypeScript 5.x — depth-first match returning the full ancestor chain

interface Matched {
  route: RouteDefinition;
  params: Record<string, string>;
}

function matchChain(
  routes: RouteDefinition[],
  segments: string[],
  params: Record<string, string> = {},
): Matched[] | null {
  for (const route of routes) {
    // A pathless layout route consumes nothing but still joins the chain.
    if (route.path === "") {
      const rest = matchChain(route.children ?? [], segments, params);
      if (rest) return [{ route, params }, ...rest];
      // An index route matches only when the path is fully consumed.
      if (segments.length === 0) return [{ route, params }];
      continue;
    }

    const own = route.path.split("/").filter(Boolean);
    if (own.length > segments.length) continue;

    const next = { ...params };
    const consumed = own.every((part, i) => {
      if (part.startsWith(":")) {
        next[part.slice(1)] = decodeURIComponent(segments[i]);
        return true;
      }
      return part === segments[i];
    });
    if (!consumed) continue;

    const remaining = segments.slice(own.length);
    if (remaining.length === 0) {
      // Exact match: descend once more to pick up an index child if present.
      const index = (route.children ?? []).find((c) => c.path === "");
      return index
        ? [{ route, params: next }, { route: index, params: next }]
        : [{ route, params: next }];
    }

    const rest = matchChain(route.children ?? [], remaining, next);
    if (rest) return [{ route, params: next }, ...rest];
  }
  return null;
}

2. Load data for the whole chain in parallel

Each level may need its own data. Requesting them sequentially — parent, then child, then grandchild — creates exactly the waterfall that nested routing is supposed to avoid. Fire every loader at once and await them together.

// TypeScript 5.x — parallel loading across the matched chain

async function loadChain(chain: Matched[]): Promise<Record<string, unknown>[]> {
  return Promise.all(
    chain.map(({ route, params }) =>
      route.load
        ? route.load({ params, data: {}, outlet: () => null })
        : Promise.resolve(undefined),
    ),
  ) as Promise<Record<string, unknown>[]>;
}

3. Render from the leaf upward

Composition runs in the opposite direction to matching. Build the innermost node first, then hand it to its parent as that parent’s outlet, and repeat until the root has been rendered.

// TypeScript 5.x — fold the chain into a single tree of nodes

function renderChain(chain: Matched[], loaded: unknown[]): Node | null {
  let child: Node | null = null;
  for (let i = chain.length - 1; i >= 0; i--) {
    const { route, params } = chain[i];
    const captured = child; // the already-built subtree for this level's outlet
    child = route.render({
      params,
      data: (loaded[i] ?? {}) as Record<string, unknown>,
      outlet: () => captured,
    });
  }
  return child;
}

4. Reconcile instead of replacing

The performance payoff only arrives if navigation reuses the levels that did not change. Compare the previous chain with the new one, find the first index where they diverge, and re-render from there down. Everything above that index keeps its existing instance.

// TypeScript 5.x — find the deepest shared prefix between two chains

function divergenceIndex(prev: Matched[], next: Matched[]): number {
  const shared = Math.min(prev.length, next.length);
  for (let i = 0; i < shared; i++) {
    const sameRoute = prev[i].route === next[i].route;
    const sameParams =
      JSON.stringify(prev[i].params) === JSON.stringify(next[i].params);
    if (!sameRoute || !sameParams) return i;
  }
  return shared; // one chain is a prefix of the other
}

Comparing params, not just route identity, matters: /users/7/posts and /users/9/posts match the same route objects but represent different users, so the level that owns :id must re-render even though its route definition is unchanged.

Matching descends the tree, rendering folds back up A two-direction diagram. Matching walks down from the root route through the layout to the leaf, collecting params. Rendering then folds upward, each level receiving the level below it as its outlet. 1 · Match downward root consumes nothing /users/:id params.id = "7" posts leaf of the chain 2 · Render upward post list node built first user layout outlet() → post list app shell outlet() → user layout chain
Matching walks down the tree accumulating params; rendering folds the chain back up, each level receiving the level beneath it as its outlet.

Verification & Testing

The behaviour worth testing is not “does the child render” — that fails loudly and you would notice. It is “did the parent survive”, which fails silently and only shows up as a subtle UX regression weeks later.

The reliable technique is to stamp the layout with a value that changes on mount, then assert it is unchanged after navigating between siblings.

// @playwright/test v1.44
import { test, expect } from "@playwright/test";

test("the settings layout is not remounted when switching sub-screens", async ({ page }) => {
  await page.goto("/settings/profile");

  // The layout writes a fresh id into a data attribute each time it mounts.
  const before = await page.locator("[data-layout='settings']").getAttribute("data-mount-id");

  // Prove the layout's own state survives, not just its markup.
  await page.locator("[data-testid='sidebar-scroller']").evaluate((el) => (el.scrollTop = 420));

  await page.getByRole("link", { name: "Billing" }).click();
  await expect(page.locator("[data-panel='billing']")).toBeVisible();

  const after = await page.locator("[data-layout='settings']").getAttribute("data-mount-id");
  expect(after).toBe(before); // same instance — no remount

  const scroll = await page
    .locator("[data-testid='sidebar-scroller']")
    .evaluate((el) => el.scrollTop);
  expect(scroll).toBe(420); // state preserved across the navigation
});

For the matcher itself, unit tests are cheaper and catch the ambiguities that end-to-end tests miss. Assert the shape of the chain, not just the leaf: matchChain(routes, ["settings"]) should return two entries when an index route exists and one when it does not, and matchChain(routes, ["users", "7", "posts"]) should carry id: "7" on both the user level and the posts level.

In DevTools, the quickest manual check is the Elements panel: expand to the shared layout node, navigate between siblings, and watch whether the node flashes purple (replaced) or stays static (reused). Chrome’s “Highlight DOM node changes” rendering option makes remounts obvious at a glance.

Performance Tuning

Load levels in parallel, always. The single largest win in a nested router is refusing to serialise loaders. If the layout fetches the user and the leaf fetches that user’s posts, and the posts request cannot start until the user request finishes, you have re-created the waterfall discussed in route-based code splitting at the data layer. Kick off every loader in the chain simultaneously, and if a leaf genuinely depends on a parent’s result, pass the promise down rather than the resolved value so the dependent request starts the moment its input is available.

Split the bundle at layout boundaries, not just leaves. A layout is often heavier than the leaves it wraps — it owns the navigation, the search, the notification bell. Making it a separate chunk means sibling navigations never re-download it, while a first visit to a different area does not pay for it at all.

Prefetch one level, not the whole subtree. When a link into a nested area is hovered, prefetch the chunks for the chain that link resolves to — and stop there. Prefetching every descendant of /settings on hover of the settings link is how a well-meaning optimisation turns into a megabyte of speculative download. The hover heuristics in prefetching and preloading routes apply unchanged; the only nesting-specific rule is to bound the depth.

Keep the divergence check cheap. The reconciliation step runs on every navigation. Comparing params with JSON.stringify is fine for the three or four keys a route typically carries, but if your params object is large, compare keys explicitly instead — it is the difference between a few microseconds and a measurable hitch on low-end devices.

Watch for layout-level layout thrash. A persistent sidebar that measures itself on every child render will read layout in the middle of the child’s write phase. Because the sidebar survives navigation, this cost is paid on every single transition rather than once per page. Move measurements into a ResizeObserver so they are batched by the browser.

Serial versus parallel loading across a route chain A timeline comparison. Serial loading runs the shell, layout and leaf requests end to end for a total of six hundred milliseconds. Parallel loading overlaps them so the total equals the slowest single request, two hundred and eighty milliseconds. Serial — each level waits for its parent shell 150ms layout 170ms leaf 280ms 600ms to first paint of the panel Parallel — all three start together shell layout leaf 280ms — the slowest request, not the sum
Nested loaders must be fired together: serialising them turns a 280 ms navigation into a 600 ms one for no benefit.

Gotchas & Failure Modes

  • A missing index route renders an empty outlet. Visiting /settings with children but no path: "" child gives you the layout and a blank panel. It looks like a data bug and is actually a routing bug.
  • Relative links resolve against the route, not the URL. In most nested routers a link to billing from inside /settings/profile resolves relative to the parent route (/settings/billing), not to the current URL (/settings/profile/billing). Teams that assume URL-relative semantics ship links that 404 only on the deeper screens.
  • Params reuse hides stale data. Because the parent is not remounted when only a param changes, effects keyed on mount will not re-run. Key them on the param value instead, or the profile of user 7 will keep showing while the URL says user 9.
  • Pathless layout routes break naive breadcrumbs. A layout route contributes a rendering level but no URL segment, so breadcrumbs generated by counting chain length will show a crumb that has no address. Generate crumbs from routes that actually have a path.
  • Error boundaries follow the tree. An error thrown in a leaf should be caught by the nearest ancestor boundary, leaving the layout intact and interactive. If your boundary sits above the layout, one failing panel blanks the whole area — the failure mode covered in route-level error boundaries in a SPA.
  • Deep trees multiply transitions. Each level that re-renders can animate. Four animating levels in one navigation reads as jank, not polish. Animate the diverging level only.