๐ช 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>anduseOutletContext - Structure multi-level layouts (app shell โ section โ detail) without duplication
- Code-split nested branches with
lazyand<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.
(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.
<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.
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/reviewsbeats 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
lazyand<Suspense>
๐ Additional Resources
- React Router โ Outlet
- React Router โ Index Routes
- React Router โ useOutletContext
- MDN โ History API
๐ 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.