Skip to main content

๐Ÿช† Nested Routes & Layouts

Real apps are rooms inside rooms: a dashboard with a sidebar that stays put while the panel beside it swaps between Overview, Analytics, and Reports. Nested routes let one URL paint a stack of components โ€” a shared shell plus the specific page inside it โ€” so you never rebuild the chrome on every navigation.

Week 5 · Day 3 (Wednesday: Advanced Routing) · Lecture 1

๐ŸŽฏ Learning Objectives

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

  • Explain how a nested <Route> maps a URL to a chain of components
  • Render child routes inside a parent layout with the <Outlet> component
  • Add an index route so a parent path shows default content
  • Share data from a layout to its children with <Outlet context> and useOutletContext
  • Structure multi-level layouts (app shell โ†’ section โ†’ detail) without duplication
  • Code-split nested branches with lazy and <Suspense>

Estimated Time: 70 minutes

Practice: Build a dashboard shell whose sidebar persists while Overview / Analytics / Reports render in an Outlet.

In This Lesson

Why Nested Routes?

Picture a dashboard. Across the top sits a header; down the left a sidebar of links. When you click Analytics, the header and sidebar shouldn't flicker or reload โ€” only the middle panel should change. Without nested routes you'd copy that header and sidebar into every page component and pray they stay in sync. With nested routes, you describe the shell once and let each child route fill in the blank.

The key idea: a single URL like /dashboard/analytics no longer maps to one component. It maps to a chain โ€” the DashboardLayout (matched by /dashboard) wrapping the Analytics page (matched by analytics). React Router walks that chain and nests the components exactly the way you nested the routes.

graph TD U["URL: /dashboard/analytics"] --> R[Router matches the chain] R --> L["DashboardLayout
(matched by /dashboard)"] L --> O["<Outlet />"] O --> A["Analytics
(matched by analytics)"]

Read that top to bottom: the URL selects a parent and a child, the parent renders its layout, and wherever the parent drops an <Outlet />, the child appears. That single component โ€” the Outlet โ€” is the whole trick.

The Outlet Component

An <Outlet /> is a placeholder that says "render my matched child route here." Think of it like the {children} prop you already know โ€” except React Router fills it in automatically based on the URL, rather than you passing it by hand.

A layout shell with header and sidebar surrounding an Outlet that swaps child pages DashboardLayout (persists) Header Sidebar <Outlet /> Overview ยท Analytics ยท Reports swap here
The header and sidebar are rendered once by the layout; only the dashed <Outlet /> region changes as you navigate.

A minimal setup

Two pieces work together: a layout component that renders the shell plus an <Outlet />, and a route config that nests child routes inside the parent.

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

// 1) The layout: shared chrome + a hole for the child
function DashboardLayout() {
  return (
    <div className="dashboard">
      <nav className="dashboard-nav">
        {/* "." = index, relative links keep you inside /dashboard */}
        <Link to=".">Overview</Link>
        <Link to="analytics">Analytics</Link>
        <Link to="reports">Reports</Link>
      </nav>

      <div className="dashboard-content">
        <Outlet />  {/* <-- the matched child route renders right here */}
      </div>
    </div>
  );
}

// 2) The routes: children nest INSIDE the parent <Route>
function App() {
  return (
    <Routes>
      <Route path="/" element={<Home />} />

      <Route path="/dashboard" element={<DashboardLayout />}>
        <Route index element={<DashboardOverview />} />   {/* /dashboard */}
        <Route path="analytics" element={<Analytics />} /> {/* /dashboard/analytics */}
        <Route path="reports" element={<Reports />} />     {/* /dashboard/reports */}
      </Route>

      <Route path="*" element={<NotFound />} />
    </Routes>
  );
}

๐Ÿ“– Child paths are relative

Notice the child paths have no leading slash: analytics, not /analytics. React Router joins them onto the parent's path for you. Writing /analytics would try to match from the site root and break the nesting.

Index Routes

What renders at /dashboard exactly โ€” with nothing after it? That's the job of the index route. An index route is the default child: it shares the parent's URL and fills the Outlet when no other child matches.

<Route path="/dashboard" element={<DashboardLayout />}>
  <Route index element={<DashboardOverview />} />  {/* shows at exactly /dashboard */}
  <Route path="analytics" element={<Analytics />} />
</Route>

An index route uses the index prop instead of a path โ€” the two are mutually exclusive. Think of it as the layout's "home page." Without one, visiting /dashboard would render the layout with an empty Outlet, which almost always looks like a bug to your users.

โœ… Redirecting an index

Sometimes you'd rather send /dashboard straight to a real sub-page. Use <Navigate> as the index element:

<Route index element={<Navigate to="analytics" replace />} />

The replace keeps the bare /dashboard entry out of the history stack, so the Back button behaves.

Multiple Levels of Nesting

Outlets stack. A layout rendered inside another layout's Outlet can drop its own Outlet, and so on โ€” giving you an app shell wrapping a section shell wrapping a detail page. The route tree mirrors the component tree one-to-one.

graph TD Root["Route / โ†’ MainLayout"] --> Home["index โ†’ Home"] Root --> Dash["dashboard โ†’ DashboardLayout"] Dash --> DashHome["index โ†’ DashboardHome"] Dash --> Reports["reports โ†’ ReportsLayout"] Reports --> RList["index โ†’ ReportsList"] Reports --> Sales["sales โ†’ SalesReport"] Reports --> Users["users โ†’ UsersReport"] Dash --> Settings["settings โ†’ SettingsLayout"] Settings --> SGen["index โ†’ GeneralSettings"] Settings --> SProf["profile โ†’ ProfileSettings"]
function App() {
  return (
    <Routes>
      <Route path="/" element={<MainLayout />}>
        <Route index element={<Home />} />

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

          <Route path="reports" element={<ReportsLayout />}>
            <Route index element={<ReportsList />} />
            <Route path="sales" element={<SalesReport />} />
            <Route path="users" element={<UsersReport />} />
          </Route>

          <Route path="settings" element={<SettingsLayout />}>
            <Route index element={<GeneralSettings />} />
            <Route path="profile" element={<ProfileSettings />} />
            <Route path="security" element={<SecuritySettings />} />
          </Route>
        </Route>
      </Route>
    </Routes>
  );
}

// Each layout drops its own <Outlet /> wherever its child should appear:
function MainLayout() {
  return (
    <div className="main-layout">
      <Header />
      <main><Outlet /></main>   {/* renders Home, DashboardLayout, ... */}
      <Footer />
    </div>
  );
}

function DashboardLayout() {
  return (
    <div className="dashboard-layout">
      <DashboardSidebar />
      <div className="dashboard-main"><Outlet /></div>  {/* renders reports, settings, ... */}
    </div>
  );
}

Why it matters: the URL /dashboard/reports/sales now renders four components at once โ€” MainLayout > DashboardLayout > ReportsLayout > SalesReport โ€” each keeping its own persistent chrome. Navigate to /dashboard/reports/users and only the innermost component changes.

โš ๏ธ v6.4+ data routers

Everything here works with the classic <Routes> element. If you adopt the newer data APIs (createBrowserRouter + RouterProvider), the same tree is expressed as a nested children array โ€” and you unlock route loaders and actions for data. The nesting concepts are identical; only the syntax shifts.

Sharing Data with Outlet Context

Layouts often own data their children need โ€” the current user, a set of preferences, a theme. Rather than reach for a global store or prop-drill through the router, React Router hands you a purpose-built channel: pass a value to <Outlet context={...} />, then read it in any child with useOutletContext().

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

// Parent layout provides the context
function UserDashboard() {
  const [user, setUser] = useState(null);
  const [preferences, setPreferences] = useState({});

  useEffect(() => {
    fetchUserData().then(data => {
      setUser(data.user);
      setPreferences(data.preferences);
    });
  }, []);

  const updatePreferences = (newPrefs) => {
    setPreferences(prev => ({ ...prev, ...newPrefs }));
    savePreferences(newPrefs);
  };

  return (
    <div className="user-dashboard">
      <UserHeader user={user} />
      <div className="dashboard-content">
        {/* everything in this object is available to child routes */}
        <Outlet context={{ user, preferences, updatePreferences }} />
      </div>
    </div>
  );
}

// Any child route reads it โ€” no props threaded through the router
function UserProfile() {
  const { user, preferences, updatePreferences } = useOutletContext();

  if (!user) return <Loading />;

  return (
    <div className="user-profile">
      <h2>{user.name}'s Profile</h2>
      <PreferencesForm preferences={preferences} onUpdate={updatePreferences} />
    </div>
  );
}

๐Ÿ’ก Typing the context (TypeScript)

In TypeScript, give the hook a type parameter so children get autocompletion and safety:

type DashCtx = { user: User; preferences: Prefs; updatePreferences: (p: Partial<Prefs>) => void };

// in the child:
const { user } = useOutletContext<DashCtx>();

A common pattern is exporting a tiny useDashContext wrapper so every child imports one typed hook.

When to reach for it: Outlet context is perfect for data scoped to one layout branch โ€” a settings section, a wizard, a workspace. For truly app-wide state (auth, theme) a React Context provider higher up is still the better home; Outlet context shines for the "this layout and its pages" middle ground.

Lazy-Loading Nested Branches

Nested routes are natural code-splitting boundaries: a user browsing the marketing site rarely needs the entire admin dashboard's JavaScript up front. Wrap heavy branches in React.lazy and a <Suspense> fallback so their bundles download only when someone actually visits them.

import { lazy, Suspense } from 'react';
import { Routes, Route, Outlet } from 'react-router-dom';

const DashboardOverview = lazy(() => import('./pages/Dashboard/Overview'));
const Analytics = lazy(() => import('./pages/Dashboard/Analytics'));
const Reports = lazy(() => import('./pages/Dashboard/Reports'));

function App() {
  return (
    <Routes>
      <Route path="/dashboard" element={<DashboardLayout />}>
        {/* One Suspense boundary around the Outlet covers every lazy child */}
        <Route
          element={
            <Suspense fallback={<LoadingSpinner />}>
              <Outlet />
            </Suspense>
          }
        >
          <Route index element={<DashboardOverview />} />
          <Route path="analytics" element={<Analytics />} />
          <Route path="reports" element={<Reports />} />
        </Route>
      </Route>
    </Routes>
  );
}

The pathless <Route> (no path, no index) exists purely to wrap its children in a Suspense boundary. Its Outlet renders the real matched page, so one fallback spinner covers all three lazy imports.

Result

Initial load โ†’ dashboard-layout.[hash].js  (small shell)
Click Analytics โ†’ analytics.[hash].js downloads on demand โ†’ spinner โ†’ page

Practice & Quiz

๐Ÿ‹๏ธ Exercise 1: A persistent dashboard shell

Goal: Build a DashboardLayout whose sidebar stays mounted while three pages swap in an Outlet. Add an index route so /dashboard shows the Overview.

// TODO: render a sidebar with links to Overview, Analytics, Reports,
// an <Outlet /> for the pages, and wire up the routes with an index.
function DashboardLayout() { /* ... */ }
function App() { /* Routes here */ }
๐Ÿ’ก Hint

The layout renders the sidebar plus <Outlet />. In the routes, nest three children inside <Route path="/dashboard" element={<DashboardLayout />}>, and make the Overview an <Route index ...>. Use relative to values in the links.

โœ… Solution
import { Routes, Route, Outlet, NavLink } from 'react-router-dom';

function DashboardLayout() {
  return (
    <div className="dashboard">
      <aside>
        <NavLink to="." end>Overview</NavLink>
        <NavLink to="analytics">Analytics</NavLink>
        <NavLink to="reports">Reports</NavLink>
      </aside>
      <main><Outlet /></main>
    </div>
  );
}

function App() {
  return (
    <Routes>
      <Route path="/dashboard" element={<DashboardLayout />}>
        <Route index element={<Overview />} />
        <Route path="analytics" element={<Analytics />} />
        <Route path="reports" element={<Reports />} />
      </Route>
    </Routes>
  );
}

The end prop on the Overview link keeps it from staying "active" when you're on a sub-page.

๐Ÿ‹๏ธ Exercise 2: Pass the current user down

Goal: Give DashboardLayout a user value and read it inside the Overview page via Outlet context โ€” no props through the router.

โœ… Solution
function DashboardLayout() {
  const [user] = useState({ name: 'Ada' });
  return (
    <div className="dashboard">
      <Sidebar />
      <Outlet context={{ user }} />
    </div>
  );
}

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

๐ŸŽฏ Quick Quiz

Question 1: What does the <Outlet /> component do?

Question 2: A child route should be written as:

Question 3: Which renders at exactly /dashboard with nothing after it?

Best Practices & Pitfalls

โœ… Do

  • Give every layout route an index route so the parent URL is never blank
  • Keep child paths relative (no leading slash) so nesting composes cleanly
  • Let the URL structure mirror your UI hierarchy: /products/:id/reviews beats four flat routes
  • Reach for Outlet context for data scoped to one layout branch
  • Code-split large branches with lazy + a single <Suspense> around the Outlet

โŒ Don't

  • Forget the <Outlet /> โ€” a layout without one silently renders nothing for its children
  • Duplicate headers/sidebars into every page instead of hoisting them into a layout
  • Put a leading slash on child paths (/analytics) โ€” it escapes the parent
  • Overload Outlet context with app-wide state that belongs in a real Context provider

โš ๏ธ The silent empty layout

The single most common nested-routes bug: you build the layout, wire the routes, visit the page โ€” and only the shell shows. The fix is almost always a missing <Outlet /> in the layout, or a missing index route for the bare parent URL. When a nested page renders "nothing," check those two first.

Summary

๐ŸŽ‰ Key Takeaways

  • Nesting <Route> elements maps one URL to a chain of components
  • <Outlet /> is the placeholder where a matched child renders inside its parent layout
  • An index route supplies the default content for the parent's exact URL
  • Outlets stack, so you can build app-shell โ†’ section โ†’ detail layouts with zero duplication
  • <Outlet context> + useOutletContext() share data from a layout to its children
  • Nested branches are ideal code-splitting boundaries via lazy and <Suspense>

๐Ÿ“š Additional Resources

๐Ÿš€ What's Next?

Your layouts are in place, but real detail pages need to know which item to show. Next up: Route Parameters โ€” capturing dynamic values like /users/:userId from the URL with useParams.

๐ŸŽ‰ Great work!

You can now build the kind of persistent, hierarchical UI every real app is made of.