Skip to content

Nested routes and layouts

Routing covered the flat version of the story: one Route per page, each with its own path and element. Real apps quickly outgrow that, because URLs nest. A path like /host/income has two parts, and often the page at the deeper path keeps most of the page at the shallower path: the same navbar, the same section tabs, with new content swapped in below. Nested routes are how React Router expresses that. You nest Route elements inside each other, and the router renders the whole matching chain at once, parents wrapping children.

So "nested routes" means two related things. The URL segments nest, and the UI nests with them: pieces of the page persist while you navigate deeper, and only the inner part changes. The second one is the real payoff.

Nesting routes and rendering children with Outlet

Route works as a self-closing element, and it also works with children. Its children must be other Route elements. When a child route matches, React Router renders the parent's element first, and the parent decides where the child's element appears by rendering an Outlet.

jsx
import { BrowserRouter, Routes, Route } from 'react-router-dom'

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route element={<Layout />}>
          <Route path="/" element={<Home />} />
          <Route path="about" element={<About />} />
          <Route path="vans" element={<Vans />} />
        </Route>
      </Routes>
    </BrowserRouter>
  )
}
jsx
import { Outlet } from 'react-router-dom'
import Header from './Header'

export default function Layout() {
  return (
    <>
      <Header />
      <Outlet />
    </>
  )
}

Outlet punches a hole in the parent's element where the matching child renders. It plays the same role the children prop plays in composition: the parent renders shared structure and marks the spot where the variable part goes. The difference is that React Router fills the hole, choosing whichever child route matches the current URL. Without an Outlet, the parent's element takes over the page and the matched child renders nowhere, which is the first thing to check when a nested page comes up blank.

That outer route above is a layout route: it has an element and no path. A pathless route matches every URL its children match, so Layout wraps every page, which is exactly where site-wide chrome like a header and footer belongs. A header rendered next to Routes does the same job while the app has exactly one layout; layout routes are what let a section like /host swap in different chrome without touching the pages underneath. The option to avoid is repeating the header inside every page component.

Layouts with a path

A layout route can also carry a path, which scopes the shared UI to one section of the app. The course's VanLife project adds a /host section with its own secondary nav, so the host pages get a second layout nested inside the first:

jsx
<Route element={<Layout />}>
  <Route path="/" element={<Home />} />
  <Route path="about" element={<About />} />
  <Route path="host" element={<HostLayout />}>
    <Route path="income" element={<Income />} />
    <Route path="reviews" element={<Reviews />} />
  </Route>
</Route>

HostLayout renders the host nav plus an Outlet, so at /host/income the router renders Layout, then HostLayout in its outlet, then Income in the host layout's outlet. Three routes match at once, and each parent frames the next. Layouts stack as deep as the design calls for.

Index routes

There is one gap in that setup: HostLayout renders at /host, but its outlet has nothing to show there, because every child adds a segment to the path. The fix is an index route: replace path with the index prop, and that child renders in the parent's outlet at the parent's own path.

jsx
<Route index element={<Dashboard />} />

Now /host shows the host nav with the dashboard inside it, and /host/income swaps the dashboard for the income page. Think of index as "the default child": what belongs in the outlet before any deeper segment is added. It works at the top level too, where <Route index element={<Home />} /> is the home page inside the root layout, the same route the opening example wrote as path="/".

Notice the child paths above: income, with no leading slash. A path that starts with / is absolute, measured from the root of the site. A path without one is relative to its parent route, so the nested income means /host/income. This keeps deep route trees maintainable: rename host to admin in one place and every descendant path follows.

Links get the same treatment. A Link rendered inside a routed element resolves relative paths against the route that rendered it, so inside HostLayout the nav can drop the /host prefix entirely:

jsx
import { Link, Outlet } from 'react-router-dom'

export default function HostLayout() {
  return (
    <>
      <nav>
        <Link to=".">Dashboard</Link>
        <Link to="income">Income</Link>
        <Link to="reviews">Reviews</Link>
      </nav>
      <Outlet />
    </>
  )
}

Two special values borrow their meaning from file system paths. to="." links to the current route, which is how the Dashboard link targets /host itself without hard-coding it. to=".." climbs upward, useful for "back to the list" links; it comes with a subtlety, since it climbs one route rather than one URL segment, which starts to matter once a single route owns several segments, and Route params and location puts it to work on a real back button.

NavLink and ancestor matching

Swap those Link elements for NavLink to style the current page, as the course does for the host nav, and ancestor matching comes into play: the router counts every ancestor of the current URL as matched, so the to="." Dashboard link reports active on /host/income and /host/reviews as well. Add end to that one link and it stays unstyled anywhere below /host.

Passing data down with useOutletContext

A layout often owns data its children need. HostVanDetail in the course fetches one van and keeps it in state, then renders a tab nav and an Outlet where the info, pricing, or photos page appears. Props can't reach through an Outlet, since the layout never renders those components directly. React Router's answer is the context prop on Outlet, read on the other side with useOutletContext:

jsx
<Outlet context={{ currentVan }} />
jsx
import { useOutletContext } from 'react-router-dom'

export default function HostVanInfo() {
  const { currentVan } = useOutletContext()

  return <h4>Name: {currentVan.name}</h4>
}

Whatever value you pass to context is what useOutletContext returns in whichever child currently fills the outlet. Passing an object and destructuring on the receiving end is the common shape, and it leaves room to add more values later. If Context is familiar, this will feel familiar too: the outlet acts as a provider scoped to exactly one hole in the page.

To nest or not to nest

Nesting earns its keep when routes share UI. That is the test. The URL alone can tempt you into nesting: /vans and /vans/:id share a segment, so why not share a route? But in VanLife those two pages share nothing visible beyond the site-wide chrome, and nesting them forces an awkward shape: a parent route with a path and no element, which falls back to rendering a bare Outlet, plus an index child to render the list. That is more structure for zero benefit.

When there is shared UI to keep on screen, nest and give the parent a layout element with an Outlet. When the only motivation is avoiding a repeated path segment, two flat routes are the simpler, clearer choice.

Version note

The examples here import from react-router-dom, which works in both v6 and v7; Routing covers what v7 changed about the packages. The nested-route concepts are identical in both versions.

Matching is a tree operation. React Router ranks every branch of the route tree against the URL and renders the entire winning branch, outermost element first, each Outlet receiving the next element down. A pathless layout route contributes nothing to matching; it only injects an element into the chain, which is why it can wrap everything. This also explains the failure mode where a parent element without an Outlet silently swallows its children: the match succeeded, the branch is three routes long, but the render chain has no hole to continue into.

Relative resolution follows the route tree rather than the URL string, and that distinction bites with dynamic segments. From a component rendered by <Route path="vans/:id"> nested under host, to=".." resolves to /host, skipping past both :id and vans, because .. climbs one route and that single route owns both segments. When you want URL-segment behavior, opt in with <Link to=".." relative="path">. Neither default is wrong; route-relative linking survives path renames, while path-relative linking matches user intuition for back buttons.

useOutletContext is literally context under the hood: Outlet wraps the matched child in a provider. That gives it context's re-render behavior, so a new object literal passed to the context prop means every component reading useOutletContext re-renders whenever the layout does, which is rarely a problem at page scale since navigating swaps the subtree anyway.

Choose between the three delivery mechanisms by scope: props where you render the child yourself, outlet context for a layout feeding whichever page fills its hole, and full context when components outside the routed subtree need the value too. One structural caution: outlet context couples children to their parent layout's shape. A component that reads useOutletContext only works mounted under a layout that provides that shape, so keep the context value small and treat it as part of the layout's public API.

JunoNest routes to keep shared UI on the page When pages share parts of the screen, like a navbar that should stay put while the content below changes, you can nest one route inside another. The parent route renders the shared part and puts an Outlet where the child page should appear.

A route with an element and no path is a layout that wraps everything, and an index route is the default child shown at the parent's own address.

If two pages share nothing on screen, there is no need to nest them.

JunoNest routes to keep shared UI on the page Wrap child routes in a parent Route, give the parent a layout element with an Outlet, and mark the default child with index. Drop leading slashes so paths and links resolve relative to their parent, which makes renames one-line changes, and use to="." to link to the current route.

When a layout owns data its outlet children need, pass it with Outlet's context prop and read it with useOutletContext.

Nest for shared UI; skip it when the only win is a shorter path string.

JunoNest routes to keep shared UI on the page The router matches a branch of the route tree and renders it outermost-in, each Outlet receiving the next element, with pathless layouts injecting elements without affecting matching. Relative links resolve against the route tree, so .. climbs one route even when that route owns multiple URL segments; relative="path" opts into segment climbing.

Outlet context is a scoped provider, convenient for layout-to-page data but a coupling point, so keep its shape small and stable.

Reserve nesting for shared UI; a parent with a path and no element is a sign the flat version was fine.

Next up: Route params and location, where a single route serves many pages.