Skip to main content

πŸͺ† Nested Routes and Layout Patterns

Dashboards, settings panels, and admin consoles all share a shell β€” a header and sidebar that stay put while the inner content changes. Nested routes let your URL structure mirror that visual hierarchy, so shared layout is written once and never re-mounts as users move around.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain how a parent route renders shared layout and where <Outlet> injects children
  • Use index routes to provide default content for a parent's exact path
  • Build pathless layout routes to wrap groups of pages in a common shell
  • Compose multiple and deeply nested layouts for dashboards and admin sections
  • Share data down the tree with useOutletContext and relative links

Estimated Time: 40–50 minutes  β€’  Difficulty: Intermediate

Hands-on: Build a dashboard shell with a persistent sidebar and swappable Overview / Profile / Settings panels.

In This Lesson

Why Nest Routes?

Nested routes let you define routes hierarchically: a parent route renders a layout, and its child routes render inside that layout. The parent stays mounted while children swap. This gives you consistent shells for related sections, no duplicated headers or sidebars, a routing tree that mirrors your UI, and easy data-sharing between parent and child.

🏒 Analogy β€” an office building. The building (parent route) has one lobby, one elevator bank, one security desk. Each floor (child route) reuses those without rebuilding them; you only change the office you walk into. Moving between rooms on a floor doesn't send you back to the street. Nested routes keep the "building" mounted and just change the "room."
flowchart TD App[App] --> Nav[Top Nav] App --> Dash[Dashboard route β€” layout] Dash --> Side[Sidebar stays mounted] Dash --> Outlet[Outlet: children render here] Outlet --> O[index β†’ Overview] Outlet --> P[profile β†’ Profile] Outlet --> S[settings β†’ Settings]

The Dashboard route owns the sidebar and a content slot. As the URL moves between /dashboard, /dashboard/profile, and /dashboard/settings, only the slot changes β€” the sidebar never re-renders from scratch.

The Outlet Component

<Outlet> is the key to nesting. It's a placeholder the parent renders to say "child routes go here." You nest <Route> elements inside a parent <Route>, and their elements appear wherever the parent placed its <Outlet>.

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

export default function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Home />} />

        {/* Parent route renders the Dashboard layout */}
        <Route path="/dashboard" element={<Dashboard />}>
          {/* Child routes render inside Dashboard's <Outlet /> */}
          <Route index element={<DashboardOverview />} />
          <Route path="profile" element={<Profile />} />
          <Route path="settings" element={<Settings />} />
        </Route>
      </Routes>
    </BrowserRouter>
  );
}

function Dashboard() {
  return (
    <div className="dashboard">
      <aside className="sidebar">
        <h2>Dashboard</h2>
        <nav>
          <Link to="/dashboard">Overview</Link>
          <Link to="/dashboard/profile">Profile</Link>
          <Link to="/dashboard/settings">Settings</Link>
        </nav>
      </aside>

      <main className="content">
        <Outlet />   {/* <-- child route renders here */}
      </main>
    </div>
  );
}

Key points from this example:

  • /dashboard/profile renders Profile inside Dashboard's <Outlet>.
  • Child paths are relative β€” profile becomes /dashboard/profile automatically.
  • The sidebar stays mounted across all three child views; only the content slot changes.

Relative links keep nesting maintainable

Inside a nested route you can use relative links so navigation doesn't hard-code the parent path. Change the parent's path once and every relative link follows:

function Dashboard() {
  return (
    <div className="dashboard">
      <nav>
        <Link to=".">Overview</Link>        {/* current route */}
        <Link to="profile">Profile</Link>   {/* child of current */}
        <Link to="settings">Settings</Link>
        <Link to="..">Back up</Link>         {/* parent route */}
      </nav>
      <Outlet />
    </div>
  );
}

πŸ“– How nested paths combine

Parent pathChild pathFull URL
/dashboardprofile/dashboard/profile
/users/:userIdposts/users/:userId/posts
/appsettings/notifications/app/settings/notifications

Index Routes

What should render at exactly /dashboard, before the user picks Profile or Settings? Without help, the parent renders but its <Outlet> is empty. An index route fills that gap: it's the default child for the parent's exact path.

<Route path="/dashboard" element={<Dashboard />}>
  {/* Renders at exactly /dashboard */}
  <Route index element={<DashboardOverview />} />

  {/* Render at their own paths */}
  <Route path="profile" element={<Profile />} />
  <Route path="settings" element={<Settings />} />
</Route>

⚠️ An index route has no path

Use the index prop instead of path="/". An index route represents the parent's own URL, so giving it a path is a mistake the compiler won't catch β€” it just won't match where you expect.

Index routes nest, too. Each layout level can have its own default view:

<Route path="/dashboard" element={<Dashboard />}>
  <Route index element={<DashboardOverview />} />

  <Route path="settings" element={<Settings />}>
    {/* Renders at /dashboard/settings */}
    <Route index element={<GeneralSettings />} />
    <Route path="account" element={<AccountSettings />} />
    <Route path="notifications" element={<NotificationSettings />} />
  </Route>
</Route>

πŸ’‘ Real example β€” a shop's category tree

Index routes make e-commerce browsing feel natural: the shop home shows featured items, each category shows all its products, and subcategories drill in β€” all while the shop chrome stays put.

<Route path="/shop" element={<Shop />}>
  <Route index element={<FeaturedProducts />} />      {/* /shop */}

  <Route path="clothing" element={<Clothing />}>
    <Route index element={<AllClothing />} />          {/* /shop/clothing */}
    <Route path="mens" element={<Mens />} />
    <Route path="womens" element={<Womens />} />
  </Route>
</Route>

Layout Route Patterns

A powerful trick: a parent route doesn't need a path at all. A pathless layout route exists purely to wrap a group of children in shared UI, without adding a segment to the URL.

function MainLayout() {
  return (
    <div>
      <SiteHeader />
      <main><Outlet /></main>
      <SiteFooter />
    </div>
  );
}

<Routes>
  {/* No path β€” just a shared shell for these pages */}
  <Route element={<MainLayout />}>
    <Route path="/" element={<Home />} />
    <Route path="/about" element={<About />} />
    <Route path="/contact" element={<Contact />} />
  </Route>
</Routes>

Because MainLayout has no path, its children keep their own top-level URLs (/, /about, /contact) while all sharing the same header and footer.

Different layouts for different sections

Combine several layout routes to give public pages, the user dashboard, and the admin console each their own chrome:

<Routes>
  {/* Public pages */}
  <Route element={<MainLayout />}>
    <Route path="/" element={<Home />} />
    <Route path="/about" element={<About />} />
  </Route>

  {/* Signed-in dashboard */}
  <Route path="/dashboard" element={<DashboardLayout />}>
    <Route index element={<DashboardHome />} />
    <Route path="profile" element={<Profile />} />
    <Route path="settings" element={<Settings />} />
  </Route>

  {/* Admin console */}
  <Route path="/admin" element={<AdminLayout />}>
    <Route index element={<AdminHome />} />
    <Route path="users" element={<UserManagement />} />
  </Route>
</Routes>

βœ… Guard once, protect the whole section

This is the payoff promised in the last lesson: put your auth check inside DashboardLayout (redirect with <Navigate> when there's no user). Every child route inherits the protection β€” no need to wrap each page individually.

function DashboardLayout() {
  const { user } = useAuth();
  if (!user) return <Navigate to="/login" replace />;
  return (
    <div className="dashboard">
      <DashboardSidebar />
      <main><Outlet /></main>
    </div>
  );
}

Deeply Nested Layouts

Layouts compose. A section inside the dashboard can have its own layout β€” a header and tab bar β€” nested within the dashboard's shell. This models complex apps cleanly: each level adds exactly the chrome it owns.

function ProjectsLayout() {
  return (
    <div className="projects">
      <header>
        <h1>Projects</h1>
        <Link to="new" className="button">New Project</Link>
      </header>
      <nav className="tabs">
        <NavLink to="." end>All</NavLink>
        <NavLink to="active">Active</NavLink>
        <NavLink to="archived">Archived</NavLink>
      </nav>
      <Outlet />
    </div>
  );
}

<Route path="/dashboard" element={<DashboardLayout />}>
  <Route index element={<DashboardOverview />} />

  {/* Projects section adds its own layout inside the dashboard */}
  <Route path="projects" element={<ProjectsLayout />}>
    <Route index element={<AllProjects />} />
    <Route path="active" element={<ActiveProjects />} />
    <Route path="archived" element={<ArchivedProjects />} />
    <Route path=":projectId" element={<ProjectDetails />} />
    <Route path="new" element={<NewProject />} />
  </Route>
</Route>

Navigating to /dashboard/projects/active now renders three nested shells at once:

  1. DashboardLayout β€” global header + sidebar
  2. ProjectsLayout β€” projects header + tab bar
  3. ActiveProjects β€” the actual list
Three levels of nested layout rendering together The DashboardLayout wraps the ProjectsLayout, which wraps the ActiveProjects content, each adding its own chrome around the next. DashboardLayout (header + sidebar) ProjectsLayout (title + tabs) ActiveProjects (the list) β€’ Redesign landing page β€’ Migrate to data router β€’ Q3 analytics dashboard Each <Outlet /> renders the box nested inside it.
Figure 1 β€” Each layout's <Outlet> renders the next level inward. Only the innermost box changes as the user switches tabs; the outer shells stay mounted.

Sharing Data with Outlet Context

A parent route often fetches data (a project, the current user) that its children need. Rather than each child re-fetching, the parent can hand data down through the <Outlet>. React Router provides useOutletContext for exactly this β€” a lightweight alternative to spinning up a React context.

import { Outlet, useOutletContext } from 'react-router-dom';

function DashboardLayout() {
  const [theme, setTheme] = useState('light');

  const context = {
    theme,
    toggleTheme: () => setTheme(t => (t === 'light' ? 'dark' : 'light')),
  };

  return (
    <div className={`dashboard ${theme}`}>
      <DashboardSidebar />
      <main>
        {/* Pass shared data to every child route */}
        <Outlet context={context} />
      </main>
    </div>
  );
}

function DashboardOverview() {
  const { theme, toggleTheme } = useOutletContext();
  return (
    <>
      <p>Current theme: {theme}</p>
      <button onClick={toggleTheme}>Toggle theme</button>
    </>
  );
}

Passing fetched data down

A common master-detail pattern: a :projectId route fetches the project once, then shares it with its Overview / Tasks / Team tabs:

function ProjectDetails() {
  const { projectId } = useParams();
  const [project, setProject] = useState(null);

  useEffect(() => {
    let active = true;
    fetch(`/api/projects/${projectId}`)
      .then(r => r.json())
      .then(data => { if (active) setProject(data); });
    return () => { active = false; };
  }, [projectId]);

  if (!project) return <p>Loading project…</p>;

  return (
    <div>
      <h1>{project.name}</h1>
      <nav>
        <NavLink to="." end>Overview</NavLink>
        <NavLink to="tasks">Tasks</NavLink>
        <NavLink to="team">Team</NavLink>
      </nav>
      {/* Share the fetched project with child tabs */}
      <Outlet context={project} />
    </div>
  );
}

function ProjectTasks() {
  const project = useOutletContext();   // the project object
  return (
    <ul>
      {project.tasks.map(t => <li key={t.id}>{t.title}</li>)}
    </ul>
  );
}
flowchart TD URL["URL: /projects/123/tasks"] --> PD["ProjectDetails reads projectId 123"] PD --> Fetch["Fetch project 123"] Fetch --> Data["project object"] Data --> Ctx["Outlet context={project}"] Ctx --> PT["ProjectTasks reads it via useOutletContext"]

πŸ’‘ When to use context vs. a loader

useOutletContext is perfect for handing already-loaded state down one or two levels. If several unrelated routes need the same server data, the data router's loader (previous lesson) is cleaner because it fetches per route and caches. Reach for the simplest tool that fits.

Hands-on Exercise

πŸ‹οΈ Build a dashboard shell

Objective: practice Outlet, index routes, and shared context.

Requirements:

  1. Create a /dashboard parent route rendering a persistent sidebar with links to Overview, Profile, and Settings.
  2. Add an index route so /dashboard shows the Overview by default.
  3. Add profile and settings child routes that render in the parent's <Outlet>.
  4. Store a userName in the layout and pass it to all children via <Outlet context>; display it on the Overview.
  5. Use relative links (to=".", to="profile") so nothing hard-codes /dashboard.
πŸ’‘ Hint

The parent renders the chrome and <Outlet context={{ userName }} />. Children call const { userName } = useOutletContext(). Remember the index child uses <Route index element={…} /> with no path.

βœ… Solution sketch
import { BrowserRouter, Routes, Route, Outlet, NavLink, useOutletContext } from 'react-router-dom';

function DashboardLayout() {
  const userName = 'Ray';
  const cls = ({ isActive }) => (isActive ? 'active' : undefined);
  return (
    <div className="dashboard">
      <aside>
        <NavLink to="." end className={cls}>Overview</NavLink>
        <NavLink to="profile" className={cls}>Profile</NavLink>
        <NavLink to="settings" className={cls}>Settings</NavLink>
      </aside>
      <main>
        <Outlet context={{ userName }} />
      </main>
    </div>
  );
}

function Overview() {
  const { userName } = useOutletContext();
  return <h1>Welcome back, {userName}!</h1>;
}

export default function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/dashboard" element={<DashboardLayout />}>
          <Route index element={<Overview />} />
          <Route path="profile" element={<h1>Profile</h1>} />
          <Route path="settings" element={<h1>Settings</h1>} />
        </Route>
      </Routes>
    </BrowserRouter>
  );
}

🎯 Quick Quiz

Question 1: What is the role of the <Outlet> component?

Question 2: You want content to appear at exactly /dashboard (the parent's own path). Which route do you add?

Question 3: A parent route fetches a project and needs to share it with its child tab routes without prop-drilling. What's the idiomatic React Router tool?

Best Practices

βœ… Do

  • Let your route tree mirror your visual hierarchy β€” one layout route per shared shell.
  • Use pathless layout routes to group pages without polluting the URL.
  • Give every layout an index route so its bare path is never a blank <Outlet>.
  • Guard a section once at its layout route; share fetched data with useOutletContext.
  • Prefer relative links (., .., child) so renaming a parent path doesn't break navigation.

❌ Don't

  • Don't forget the <Outlet> in a layout β€” children silently won't render.
  • Don't give an index route a path; use the index prop.
  • Don't nest so deeply that the tree becomes hard to follow β€” flatten sections that don't share chrome.
  • Don't reach for useOutletContext when data is needed app-wide; a proper context or the data router fits better.

Summary & Quiz

πŸŽ‰ Key Takeaways

  • Nested routes let a parent render shared layout while children swap inside its <Outlet>; the parent never re-mounts.
  • Index routes (<Route index>, no path) supply default content for a parent's exact URL.
  • Pathless layout routes wrap groups of pages in common chrome without adding a URL segment β€” the clean place for a section-wide auth guard.
  • Layouts compose to any depth; each <Outlet> renders the next level inward.
  • useOutletContext passes parent-loaded data down to children without prop-drilling.

πŸ“š Further Reading

πŸš€ What's Next?

You've finished the routing mini-series. Next up: Memoization with React.memo, where you'll shift from navigation to performance β€” stopping needless re-renders so your nicely structured app stays fast.

πŸŽ‰ Excellent work!

You can now architect dashboards and admin consoles whose URLs and layouts line up perfectly. That's a hallmark of a well-built React app.