Nested Routes and Outlet

Most real applications share layout between pages: a navigation bar that stays put while the page content underneath it changes, or a dashboard shell that wraps several sub-sections. React Router handles this with nested routes — routes defined inside other routes — combined with the Outlet component, which tells a parent route where to render whichever child route currently matches. Instead of repeating your header and nav in every page component, you write it once in a layout component and let React Router swap out only the part that changes.

Overview / How Nested Routes Work

In React Router v6+, routes can be nested by placing <Route> elements inside other <Route> elements. A parent route’s element is rendered whenever the current URL starts matching its path, and its children take over matching whatever comes after that segment of the URL. The parent component doesn’t need to know anything about which child matched — it just renders an <Outlet /> somewhere in its JSX, and React Router fills that slot with the matched child route’s element.

This mirrors how the URL itself is structured. A path like /dashboard/users/42 naturally decomposes into layers: a dashboard layer, a users layer, and a specific user layer. Nested routes let your component tree mirror that same hierarchy — a DashboardLayout wraps a UsersList, which in turn could wrap a UserDetail. Each layer renders its own persistent UI (navigation, headers, sidebars) and delegates the next layer down to an Outlet.

Under the hood, <Routes> walks its route tree and builds a list of matches ranked by specificity — not by the order routes are written. For a given URL, it finds the deepest branch of nested routes whose combined paths match, then renders the matched elements from the outside in: the top-level layout renders first, its Outlet renders the next matched route, and so on until the leaf route is reached. If a parent route has no Outlet in its JSX, its matched children never appear on screen, even though the URL matched them — this is the single most common nested-routing bug.

Nested routes also unlock two convenience features: index routes, which render a default child when the URL matches the parent path exactly (no further segments), and relative linking, where <Link to="..."> and useNavigate() paths without a leading slash resolve relative to the current route’s matched path rather than the domain root.

Syntax

<Routes>
  <Route path="/parent" element={<ParentLayout />}>
    <Route index element={<DefaultChild />} />
    <Route path="child" element={<Child />} />
    <Route path="child/:id" element={<ChildDetail />} />
  </Route>
</Routes>
Part Meaning
path="/parent" URL segment the parent route matches; children match whatever follows it.
element={<ParentLayout />} Component rendered for the parent; it must include <Outlet /> to show children.
index Marks a child as the default route rendered when the URL matches the parent exactly, with no extra segments.
path="child" (no leading slash) A relative child path — it is appended to the parent’s matched path, becoming /parent/child.
<Outlet /> Placed inside the parent’s JSX; renders whichever child route element currently matches.

Examples

Example 1: A shared layout with Outlet

import { createRoot } from "react-dom/client";
import { BrowserRouter, Routes, Route, Outlet, Link } from "react-router-dom";

function Layout() {
  return (
    <div>
      <nav>
        <Link to="/">Home</Link>
        <Link to="/about">About</Link>
        <Link to="/contact">Contact</Link>
      </nav>
      <hr />
      <Outlet />
    </div>
  );
}

function Home() {
  return <h1>Welcome Home</h1>;
}

function About() {
  return <h1>About Us</h1>;
}

function Contact() {
  return <h1>Contact Us</h1>;
}

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Layout />}>
          <Route index element={<Home />} />
          <Route path="about" element={<About />} />
          <Route path="contact" element={<Contact />} />
        </Route>
      </Routes>
    </BrowserRouter>
  );
}

const root = createRoot(document.getElementById("root"));
root.render(<App />);

The nav and the horizontal rule are defined once in Layout and stay on screen across every page. Visiting / shows the nav plus “Welcome Home” (because Home is the index route). Visiting /about keeps the same nav visible but swaps the Outlet content to “About Us”. Nothing in Layout re-mounts when you navigate between these — only the Outlet‘s content changes.

Example 2: Nested routes with dynamic params and relative links

import { Routes, Route, Outlet, Link, useParams } from "react-router-dom";

function DashboardLayout() {
  return (
    <div>
      <h2>Dashboard</h2>
      <nav>
        <Link to="/dashboard">Overview</Link>
        <Link to="/dashboard/users">Users</Link>
      </nav>
      <Outlet />
    </div>
  );
}

function DashboardHome() {
  return <p>Select a section above.</p>;
}

function UsersList() {
  return (
    <ul>
      <li><Link to="1">Alice</Link></li>
      <li><Link to="2">Bob</Link></li>
    </ul>
  );
}

function UserDetail() {
  const { userId } = useParams();
  return <p>Viewing user #{userId}</p>;
}

function DashboardRoutes() {
  return (
    <Routes>
      <Route path="/dashboard" element={<DashboardLayout />}>
        <Route index element={<DashboardHome />} />
        <Route path="users" element={<UsersList />} />
        <Route path="users/:userId" element={<UserDetail />} />
      </Route>
    </Routes>
  );
}

export default DashboardRoutes;

Inside UsersList, the links use to="1" and to="2" — no leading slash. Because these links are rendered while the current matched route is /dashboard/users, React Router resolves them relative to that path, producing /dashboard/users/1 and /dashboard/users/2. Clicking “Alice” navigates to /dashboard/users/1, which matches the users/:userId route and renders UserDetail, reading userId from useParams() to display “Viewing user #1”.

Example 3: Three levels of nesting

import { Routes, Route, Outlet, Link } from "react-router-dom";

function AppLayout() {
  return (
    <div className="app-shell">
      <header>My App</header>
      <Outlet />
    </div>
  );
}

function SettingsLayout() {
  return (
    <div className="settings-shell">
      <h2>Settings</h2>
      <nav>
        <Link to="profile">Profile</Link>
        <Link to="security">Security</Link>
      </nav>
      <Outlet />
    </div>
  );
}

function ProfileSettings() {
  return <p>Update your profile information here.</p>;
}

function SecuritySettings() {
  return <p>Manage your password and two-factor authentication.</p>;
}

function App() {
  return (
    <Routes>
      <Route path="/" element={<AppLayout />}>
        <Route path="settings" element={<SettingsLayout />}>
          <Route path="profile" element={<ProfileSettings />} />
          <Route path="security" element={<SecuritySettings />} />
        </Route>
      </Route>
    </Routes>
  );
}

export default App;

Visiting /settings/profile renders three layers stacked inside one another: AppLayout‘s header wraps SettingsLayout‘s heading and nav, which wraps ProfileSettings‘s paragraph. Each layout owns exactly the part of the page that belongs to it, and each has its own Outlet pointing at the next level down. This pattern scales cleanly to real apps with many nested sections — each layout only needs to know about its own direct children.

How It Works Step by Step

On initial load or navigation: React Router matches the current URL against the full route tree and picks the deepest matching branch. It renders the outermost matched route’s element first. Wherever that element’s JSX contains <Outlet />, React Router inserts the next route down in the matched branch, repeating this until it reaches the leaf route with no further children.

On navigation to a sibling route: If you navigate from /dashboard/users to /dashboard, the shared ancestor (DashboardLayout) does not unmount or re-render from scratch — its component instance persists, preserving any local state it holds (like scroll position or open menus). Only the content inside its Outlet swaps from UsersList to DashboardHome.

On navigation to an unrelated branch: If the new URL shares no common ancestor route, React unmounts the old matched components (running any useEffect cleanup functions) and mounts the new branch from the top.

Common Mistakes

Mistake 1: Forgetting the <Outlet /> in the layout.

function Layout() {
  return (
    <div>
      <nav>{/* links */}</nav>
    </div>
  );
}
// Child routes match the URL, but nothing renders — there is nowhere for them to go.

Fix: always include <Outlet /> where the child content should appear.

function Layout() {
  return (
    <div>
      <nav>{/* links */}</nav>
      <Outlet />
    </div>
  );
}

Mistake 2: Giving a nested route a leading slash.

<Route path="/dashboard" element={<DashboardLayout />}>
  <Route path="/users" element={<UsersList />} />
</Route>
// The leading slash makes this an absolute path from the domain root,
// so it no longer nests under "/dashboard" as intended.

Fix: omit the leading slash so the child path is treated as relative to its parent.

<Route path="/dashboard" element={<DashboardLayout />}>
  <Route path="users" element={<UsersList />} />
</Route>

Mistake 3: No index route, leaving the parent path blank.

<Route path="/dashboard" element={<DashboardLayout />}>
  <Route path="users" element={<UsersList />} />
</Route>
// Visiting exactly "/dashboard" renders DashboardLayout, but its Outlet
// has no matching child, so the content area is empty.

Fix: add an index route so the parent path has sensible default content.

<Route path="/dashboard" element={<DashboardLayout />}>
  <Route index element={<DashboardHome />} />
  <Route path="users" element={<UsersList />} />
</Route>

Best Practices

  • Put shared UI (nav bars, sidebars, headers) in a layout component and render an <Outlet /> where the page-specific content belongs, instead of repeating that UI in every page component.
  • Give every parent route with children an index route so the bare parent path always has content to show.
  • Write nested route path values without a leading slash, so they resolve relative to their parent and stay easy to move around the tree.
  • Use relative to values in <Link> and useNavigate() calls inside nested routes — they stay correct even if you later rename or move a parent path.
  • Keep layout components focused on structure and navigation; leave data fetching and business logic to the leaf route components they wrap.
  • For deeply nested UIs, mirror the route tree in your folder structure so each layout’s children are easy to find.

Practice Exercises

  • Build a /shop layout with a persistent header, an index route showing “Browse our products”, and a nested products/:productId route that reads the id with useParams and displays it.
  • Take the three-level /settings/profile and /settings/security example and add a fourth level: a notifications child route under security with its own small layout and an Outlet.
  • Deliberately remove the <Outlet /> from a layout component you’ve written, confirm the child route’s content disappears even though the URL still matches, then add it back and confirm the content returns.

Summary

  • Nested routes let a parent route’s layout persist while only the matched child content changes.
  • <Outlet /> is the placeholder inside a parent’s JSX where React Router renders the currently matched child route.
  • An index route supplies default content when the URL matches the parent path exactly, with no further segments.
  • Child route paths should be relative (no leading slash) so they nest correctly under their parent and support relative <Link> navigation.
  • Shared ancestor layouts persist across navigation between sibling routes, preserving their local component state.
  • A missing Outlet, an accidental absolute child path, or a missing index route are the most common reasons nested content fails to appear.