---
title: 'Nested Routes and Layouts'
source: 'https://academia.sh/en/courses/frontend-architecture/nested-routes-and-layouts'
course: 'Application Architecture: Routing, State and Data'
language: en
updated: '2026-08-17T18:11:05+00:00'
license: 'CC BY-SA 4.0'
---

# Nested Routes and Layouts

Defining routes as a tree; layout components and the outlet, the index route, the active layout chain produced from a match, the shell preserved during navigation, and handling parameter changes separately.

The previous lesson's matcher worked on a flat table: each path bound to a single view.
The North Slope Measurement Station application's screens, though, share a common
shell. The station detail view and the measurement history show the same header, the
same side navigation, and the same station profile; only the middle region changes.

With a flat table, this shared shell has to be rebuilt in every route's component. The
result is three separate defects: the same markup repeats in multiple places, the shell
repaints on every navigation, and state inside the shell — an open menu, a scrolled
list — resets on every navigation. This lesson solves all three by defining routes as a
tree.

## The Route Tree

An address's path is already hierarchical. `/station/north-slope/measurements` is made
of three segments, and each one narrows down the one before it. The route definition
can be written the same way, nested: each node takes on one segment and hands the
remaining segments to the nodes below it.

Nodes in the tree serve two purposes. A **layout component** node builds the
surroundings for every view beneath it: header, navigation, frame. A leaf node fills in
the part of the screen that changes. A layout component declares where the child view
goes with an **outlet**. This is routing's counterpart to the slot concept defined in
the Templates and Slots lesson in the Web Components and Offline topic: the content
comes from outside, and the container says where to place it.

A node's own path is written **relative**. The `:id` node sits beneath the `station`
node, so its full path becomes `/station/:id`; the definition never repeats this
prefix. This way, when an entire subtree moves elsewhere, the definitions inside it
do not change.

A third kind of node is needed. When the path `/station` is requested, the
`StationShell` layout matches, but there is no child view to place in the outlet. An
**index route** fills this gap: a child with no segment of its own that matches once
the path is exhausted. The index route is the layout's default content.

## From Match to Chain

Matching against the tree proceeds root to leaf, consuming segments along the way. The
result is not a single component but a **layout chain** running from root to leaf. On
screen, this chain nests inside out: each component hosts the next one in its own
outlet.

```js
// route-tree.mjs — producing the active layout chain from a nested route tree
const tree = {
  path: "", component: "Shell", children: [
    { index: true, component: "Welcome" },
    { path: "session", component: "SignIn" },
    {
      path: "station", component: "StationShell", children: [
        { index: true, component: "StationList" },
        { path: "new", component: "StationForm" },
        {
          path: ":id", component: "StationProfile", children: [
            { index: true, component: "StationSummary" },
            { path: "measurements", component: "MeasurementHistory" },
            { path: "settings", component: "StationSettings" },
          ],
        },
      ],
    },
    { path: "*", component: "NotFound" },
  ],
};

const ORDER = (d) => d.index ? 0 : d.path === "*" ? 3 : d.path.startsWith(":") ? 2 : 1;

function resolve(node, remaining, chain, params) {
  if (node.index) return remaining.length === 0
    ? { chain: [...chain, node.component], params } : null;

  let consumed = 0, p = params;
  if (node.path === "*") { p = { ...p, rest: remaining.join("/") }; consumed = remaining.length; }
  else if (node.path !== "") {
    if (remaining.length === 0) return null;
    if (node.path.startsWith(":")) p = { ...p, [node.path.slice(1)]: decodeURIComponent(remaining[0]) };
    else if (node.path !== remaining[0]) return null;
    consumed = 1;
  }

  const newChain = [...chain, node.component];
  const newRemaining = remaining.slice(consumed);
  for (const child of [...(node.children ?? [])].sort((a, b) => ORDER(a) - ORDER(b))) {
    const result = resolve(child, newRemaining, newChain, p);
    if (result) return result;
  }
  return (node.children ?? []).length === 0 && newRemaining.length === 0
    ? { chain: newChain, params: p } : null;
}

const match = (path) => resolve(tree, path.split("/").filter(Boolean), [], {});

const paths = ["/", "/session", "/station", "/station/new", "/station/north-slope",
  "/station/north-slope/measurements", "/station/north-slope/settings", "/report/2024"];

console.log("-- active layout chain --");
for (const path of paths) {
  const { chain, params } = match(path);
  console.log(path.padEnd(30), chain.join(" > ").padEnd(52), JSON.stringify(params));
}

console.log("-- preserved and changed during navigation --");
const transitions = [
  ["/station", "/station/north-slope"],
  ["/station/north-slope", "/station/north-slope/measurements"],
  ["/station/north-slope/measurements", "/station/north-slope/settings"],
  ["/station/north-slope/measurements", "/station/east-ridge/measurements"],
  ["/station/north-slope/measurements", "/session"],
];
for (const [previous, next] of transitions) {
  const a = match(previous), b = match(next);
  let common = 0;
  while (common < a.chain.length && common < b.chain.length &&
    a.chain[common] === b.chain[common]) common += 1;
  const paramsEqual = JSON.stringify(a.params) === JSON.stringify(b.params);
  console.log(
    `${previous} -> ${next}`.padEnd(58),
    "preserved:", String(common),
    "| changed:", (b.chain.slice(common).join(" > ") || "-").padEnd(24),
    "| params equal:", paramsEqual,
  );
}
```

```
-- active layout chain --
/                              Shell > Welcome                                      {}
/session                       Shell > SignIn                                       {}
/station                       Shell > StationShell > StationList                   {}
/station/new                   Shell > StationShell > StationForm                   {}
/station/north-slope           Shell > StationShell > StationProfile > StationSummary {"id":"north-slope"}
/station/north-slope/measurements Shell > StationShell > StationProfile > MeasurementHistory {"id":"north-slope"}
/station/north-slope/settings  Shell > StationShell > StationProfile > StationSettings {"id":"north-slope"}
/report/2024                   Shell > NotFound                                     {"rest":"report/2024"}
-- preserved and changed during navigation --
/station -> /station/north-slope                           preserved: 2 | changed: StationProfile > StationSummary | params equal: false
/station/north-slope -> /station/north-slope/measurements  preserved: 3 | changed: MeasurementHistory       | params equal: true
/station/north-slope/measurements -> /station/north-slope/settings preserved: 3 | changed: StationSettings          | params equal: true
/station/north-slope/measurements -> /station/east-ridge/measurements preserved: 4 | changed: -                        | params equal: false
/station/north-slope/measurements -> /session              preserved: 1 | changed: SignIn                   | params equal: false
```

The root node's own path is empty; it consumes no segment but still stands at the head
of the chain. This is the definition of the shell that wraps the entire application. A
parameter is extracted at a single node in the tree and reaches every view beneath that
node with the same value: neither the measurement history nor the station settings
resolves the `id` value again.

## What is Preserved During Navigation

The second part of the output shows the real payoff of nested routing in numbers. The
common prefix of two chains is the set of components that **stay in place** during
navigation; everything after it changes.

Moving from the measurement history to the station settings preserves three components.
The shell, the station shell, and the station profile stay in place in the tree; only
the innermost outlet fills with new content. Because they are not repainted, the state
inside them keeps living too: an open side navigation stays open, and the profile's
data is not requested again.

Moving to the sign-in screen drops the preserved count to one. The rest of the chain is
torn down; the local state of the torn-down components is lost. This is not a side
effect of the nested route definition — it is the decision the definition directly
expresses: the shape of the tree determines which part survives which navigations.

The fourth row shows a trap all on its own. Moving from the `north-slope` station to
the `east-ridge` station leaves the layout chain **exactly the same** — all four
components are preserved — but the parameter has changed. Because the components stay
in place, they do not get rebuilt; the screen keeps showing the previous station's data.
The correct behavior is for parameter-dependent data to also listen for the parameter
changing. This is routing's counterpart to the dependency rule from the Side Effects
lesson in the Component-Based Interface Development course: a data request's
dependency is not the component's existence, it is the parameter's value.

## Where the Not-Found Route Sits

Wherever the wildcard node sits in the tree, the not-found view appears inside that
level's shell. In the example, the wildcard is a child of the root; requesting
`/report/2024` produces a chain made of the shell and the not-found view, so the user
keeps seeing the top header and navigation.

Had the wildcard been placed under the `station` node instead, the path
`/station/north-slope/unknown` would show an error inside the station shell; the user
would not lose the station context. Both are valid designs, and the choice depends on
where the user should be able to return to. The only wrong choice is never defining the
wildcard at all: then an undefined address produces a blank screen.

The server-side counterpart should not be forgotten. The client rendering a not-found
view does not mean the server returns a successful response for that address; per the
rule established in the History and Location lesson, a genuinely missing address must
still get a not-found status code.

## Pathless Layout Nodes

Not every layout in the tree has to consume a segment. A node with an empty path of its
own adds only a frame, without changing the address. This has two uses.

The first is visual grouping: if several routes share the same narrow-column layout,
that layout is lifted to a common parent node without adding a new segment to the
address hierarchy. The address the user sees stays simple.

The second is behavior sharing: routes that share the same access condition or the same
data load are grouped under a pathless node. How the access condition gets enforced at
these nodes is the subject of the next two lessons.

There is one more option in the opposite direction: a node that consumes a segment but
has no view of its own. This node deepens the address hierarchy without adding anything
to the screen; the child view goes straight into the parent's outlet.

## Summary

- The route tree turns the address's hierarchy into the route definition's hierarchy;
  each node takes on one segment and hands the remaining path to its children.
- A layout component hosts the child view in an outlet; the index route supplies the
  layout's default content once the path is exhausted.
- A match's output is not a single component but a layout chain running from root to
  leaf; a parameter is extracted once in the tree and reaches every view beneath it.
- During navigation, the common prefix of two chains stays in place and the state
  inside it is preserved; the part that diverges is torn down and rebuilt.
- When the chain stays the same and only the parameter changes, the components are not
  rebuilt; parameter-dependent data has to track that change as its own dependency.
- The wildcard node's level in the tree determines which shell the not-found view
  appears inside.

## Next Step

The tree delivered the state carried by the path — which station, which section — to
the components. The rest of the screen's state, though, is not in the address yet. The
measurement history page might have a date range selected, a measurement type
filtered, the list on its second page, and sorted descending by date. When these live
in the component's local variables, the user cannot share that view, bookmark it, or
return to the previous filter with the back button. The next lesson covers which state
goes into the path and which into the query, how query state is serialized and parsed
back, and how default values are left out without cluttering the address.
