React Router Nested Routes: A Practical Guide for 2026
You usually notice the need for nested routing late.
It starts when your SaaS dashboard has grown past a few screens. The account sidebar shows up in one component tree, billing tabs live in another, and your team has copied the same header into analytics, reports, users, settings, and admin pages. Then someone asks for a new section inside Settings, and a simple UI change turns into a routing cleanup project.
That's where React Router nested routes stop being a syntax feature and start becoming architecture. They let you model the app the way users experience it: a stable shell around changing content. In a B2B product, that usually means a persistent sidebar, top navigation, account switcher, breadcrumbs, and then one panel in the middle that swaps based on the route.
Why Your SaaS App Needs Nested Routes
A growing dashboard usually breaks down in predictable ways. Teams duplicate layout code because each page owns too much of its own structure. Profile pages turn into big conditional components with tabs, if statements, and state that really belongs in the URL. Navigation becomes fragile because changing a shared element means touching too many files.

Nested routing fixes that by shifting the mental model. Instead of treating every screen like a separate page replacement, you define a parent route that owns the shared UI and child routes that swap only the inner content. That matches how most SaaS apps behave.
If you're still weighing overall app navigation strategy, this breakdown of single page application versus multi page is useful context. It helps frame why route-driven UI composition matters so much once an internal product starts acting more like a desktop app than a marketing site.
The dashboard problem nested routes solve
Take a common account area:
/app/settings/app/settings/profile/app/settings/billing/app/settings/security
Without nested routes, developers often build one large SettingsPage and manually switch sections. That works at first, but it tends to create a few problems:
- URL drift: The visible state and the browser location stop matching cleanly.
- Shared layout duplication: The section nav gets rebuilt in multiple places.
- Testing pain: You can't verify layout and route behavior as one integrated flow.
With React Router nested routes, the settings shell becomes the parent. The tabs or sub-navigation stay mounted, and child routes render inside a dedicated placeholder.
Practical rule: If a page has shared chrome and only one inner panel changes, it probably wants nested routes.
Why this matters more in B2B SaaS
In a consumer app, a lot of navigation can stay shallow. In B2B SaaS, that's rarely enough. You have organizations, teams, user roles, billing, audit logs, integrations, and feature areas that live inside a persistent app shell. Nested routes support that structure naturally.
They also push teams toward maintainable composition. Instead of thinking, “which page component should own this whole screen,” you start thinking, “which route owns the layout, and which route owns the changing content.” That's a much better fit for scalable UI work.
Implementing Your First Nested Route with Outlet
The core idea is simple. A parent route renders the shared layout. An <Outlet /> inside that layout marks where the matched child route should appear.
A useful way to learn this is with a settings area, because it mirrors a real product interface. The parent route owns the settings header and sub-navigation. The child routes render the ProfilePage or BillingPage.

A minimal route setup
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import SettingsLayout from './SettingsLayout';
import SettingsHome from './SettingsHome';
import ProfilePage from './ProfilePage';
import BillingPage from './BillingPage';
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/settings" element={<SettingsLayout />}>
<Route index element={<SettingsHome />} />
<Route path="profile" element={<ProfilePage />} />
<Route path="billing" element={<BillingPage />} />
</Route>
</Routes>
</BrowserRouter>
);
}
This is the heart of React Router nested routes. The settings route is the parent. Its children are defined inside it. Notice that the child paths are relative, so you write "profile" instead of "/settings/profile".
That relative path behavior became a key part of the v6-era routing model. React Router's nested-routes approach became especially important with the move to declarative layouts, where a parent route plus an <Outlet> lets child routes render inside the parent UI instead of replacing the whole page. That same period also introduced relative child paths and the use of a trailing * on parent paths to match deeper segments such as /about/offers, as described in Robin Wieruch's write-up on React Router nested routes and declarative layouts.
The parent layout component
import { NavLink, Outlet } from 'react-router-dom';
export default function SettingsLayout() {
return (
<section>
<header>
<h1>Settings</h1>
<nav>
<NavLink to="" end>General</NavLink>
<NavLink to="profile">Profile</NavLink>
<NavLink to="billing">Billing</NavLink>
</nav>
</header>
<div className="settings-content">
<Outlet />
</div>
</section>
);
}
<Outlet /> is the placeholder. When the current URL matches /settings/profile, React Router renders ProfilePage inside that outlet. The parent layout stays in place.
If you've used dynamic rendering patterns elsewhere in your codebase, this feels familiar. The difference is that routing now controls the active view. That usually scales better than manually choosing components from local state, though there are still cases where dynamic React component rendering patterns make sense inside a routed screen.
Keep the route in charge of view-level state. Use component state for local interaction, not for replacing entire sections that deserve a URL.
Why this pattern holds up
In a real SaaS app, the parent layout often contains:
- Persistent UI: Tabs, breadcrumbs, contextual actions, section title
- Shared data wiring: Permissions, organization context, feature flags
- Consistent framing: Padding, page width, loading shell, empty-state placement
The child route then stays focused. BillingPage doesn't need to know how the settings header works. ProfilePage doesn't recreate the nav.
Here's a useful test. If you can delete duplicated wrappers from multiple pages and replace them with one layout route, you're using nested routes correctly.
Later, once the basics click, it helps to watch a full example in motion:
Architecting a Scalable Dashboard Layout
A B2B dashboard usually has one stable shell and many inner views. The shell includes the sidebar, top bar, account controls, maybe a search command bar. The inner view changes when the user moves from analytics to reports to team members.
That's exactly the structure nested routes are meant to express.

A route tree that fits a SaaS product
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import DashboardLayout from './features/dashboard/layouts/DashboardLayout';
import AnalyticsPage from './features/dashboard/pages/AnalyticsPage';
import ReportsPage from './features/dashboard/pages/ReportsPage';
import TeamMembersPage from './features/dashboard/pages/TeamMembersPage';
import DashboardHomePage from './features/dashboard/pages/DashboardHomePage';
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/dashboard" element={<DashboardLayout />}>
<Route index element={<DashboardHomePage />} />
<Route path="analytics" element={<AnalyticsPage />} />
<Route path="reports" element={<ReportsPage />} />
<Route path="team-members" element={<TeamMembersPage />} />
</Route>
</Routes>
</BrowserRouter>
);
}
And the layout:
import { NavLink, Outlet } from 'react-router-dom';
export default function DashboardLayout() {
return (
<div className="dashboard-shell">
<aside className="sidebar">
<NavLink to="" end>Overview</NavLink>
<NavLink to="analytics">Analytics</NavLink>
<NavLink to="reports">Reports</NavLink>
<NavLink to="team-members">Team Members</NavLink>
</aside>
<div className="dashboard-main">
<header className="topbar">
<h1>Workspace</h1>
</header>
<main className="dashboard-content">
<Outlet />
</main>
</div>
</div>
);
}
When someone clicks Reports, only the outlet content changes. The shell stays mounted. That preserves navigation state and keeps the code aligned with the UI.
A folder structure that stays sane
Teams often make routing messy by organizing everything under a generic pages directory. That works until the app has enough nested areas that you can't tell what belongs to what.
For a dashboard, a feature-first layout usually ages better:
| Folder | What goes there |
|---|---|
features/dashboard/layouts |
DashboardLayout, section shells, route-level wrappers |
features/dashboard/pages |
top-level child pages like Analytics, Reports, Team Members |
features/dashboard/components |
charts, tables, filters, cards |
features/dashboard/routes |
route definitions or route config helpers |
features/dashboard/hooks |
dashboard-specific hooks |
This structure works especially well when sections become more complex and need their own sub-layouts. Reports may later get a list route, detail route, and export settings route. You won't have to reorganize the whole app to support it.
A similar principle shows up when teams embed UI pieces across boundaries. If your application also mixes frameworks or host environments, this guide to ReactJS web components integration is useful background for deciding where layout responsibility should live.
What scales and what doesn't
A few trade-offs show up quickly:
- What works well: One layout route per real UI shell. Dashboard shell, settings shell, admin shell.
- What usually fails: A route tree that mirrors folders blindly, even when the UI doesn't share a layout.
- What helps teams: Naming layouts after product areas, not technical abstractions.
Shared layout should follow shared user experience. Don't create a parent route just because two files happen to live next to each other.
That distinction matters. Routes are not just URL handlers. In a SaaS app, they're one of the main ways you declare which parts of the interface persist and which parts change.
Advanced Patterns for Nested Routes
Once the basic dashboard layout is in place, a few patterns separate a demo from a production app. The big ones are access control, code splitting, and dynamic route segments.
Each solves a different business problem. One protects sensitive areas. One keeps the app lighter at startup. One lets the URL point to real records like users, reports, or organizations.

Protecting nested sections
Admin areas should not rely on “hide the link” as security. Route protection belongs in the routing layer too.
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { useAuth } from './useAuth';
function ProtectedRoute() {
const { user, isLoading } = useAuth();
const location = useLocation();
if (isLoading) return <div>Loading...</div>;
if (!user) {
return <Navigate to="/login" replace state={{ from: location }} />;
}
return <Outlet />;
}
Used like this:
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<ProtectedRoute />}>
<Route path="/dashboard" element={<DashboardLayout />}>
<Route index element={<DashboardHomePage />} />
<Route path="analytics" element={<AnalyticsPage />} />
<Route path="reports" element={<ReportsPage />} />
</Route>
</Route>
</Routes>
This keeps all protected children behind one wrapper. It's cleaner than repeating auth checks inside every page component.
Lazy loading child routes
Large dashboards often suffer from a different problem. The shell loads quickly, but the bundle includes reports code, billing code, admin code, and other views the user may not touch during that session.
Code-splitting nested routes helps:
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
const DashboardLayout = lazy(() => import('./DashboardLayout'));
const AnalyticsPage = lazy(() => import('./AnalyticsPage'));
const ReportsPage = lazy(() => import('./ReportsPage'));
export default function App() {
return (
<Suspense fallback={<div>Loading view...</div>}>
<Routes>
<Route path="/dashboard" element={<DashboardLayout />}>
<Route path="analytics" element={<AnalyticsPage />} />
<Route path="reports" element={<ReportsPage />} />
</Route>
</Routes>
</Suspense>
);
}
This is especially useful when a route pulls in heavy charts, editors, or table libraries. Users shouldn't pay the cost for features they haven't opened yet.
Load the dashboard shell early. Load specialized child views when the route demands them.
Dynamic segments for record-level screens
Most B2B apps need nested detail pages. Users open a team member, a report, a project, or a customer record. Dynamic segments make that structure readable and maintainable.
import { Routes, Route, Outlet, useParams } from 'react-router-dom';
function UsersLayout() {
return <Outlet />;
}
function UserSettingsPage() {
const { userId } = useParams();
return <div>User settings for {userId}</div>;
}
<Routes>
<Route path="/users" element={<UsersLayout />}>
<Route path=":userId/settings" element={<UserSettingsPage />} />
</Route>
</Routes>
This pattern matters because the route communicates hierarchy. /users/:userId/settings tells you you're inside a user context, then inside that user's settings context. That's easier to reason about than flattening everything into unrelated top-level paths.
Common Pitfalls and Performance Considerations
Most nested-route bugs are not subtle. The UI goes blank, the expected child never renders, or the app shows duplicated navigation because layout responsibilities got split in the wrong place.
The common issues are known. A practical pattern for nested routing is to preserve shared UI while swapping only the inner fragment of the page. The common mistakes include forgetting the outlet in the parent component, misplacing the index route, and repeating the parent segment in child paths, which can break rendering or duplicate navigation logic, as outlined in this nested routes implementation guide.
The bugs that show up first
Here's the short diagnostic table I use when someone says, “the route exists, but nothing renders.”
| Symptom | Likely cause | Fix |
|---|---|---|
| Parent renders, child area is blank | Missing <Outlet /> |
Add <Outlet /> to the parent layout where child content should appear |
/dashboard doesn't show default child |
Index route missing or misplaced | Add <Route index element={...} /> inside the parent route |
| Child route never matches | Child path repeats parent path | Use relative child paths like "reports" instead of "/dashboard/reports" inside the parent |
| Nav appears twice | Shared layout duplicated in child component | Move shell UI into the parent layout route |
That last one is common in dashboards. A developer builds a DashboardLayout, but the ReportsPage still wraps itself in a dashboard container. The app works, but the route tree no longer reflects the UI tree.
Performance isn't free just because the route is nested
Nested routes help composition, but they don't automatically prevent unnecessary rendering. If your DashboardLayout recreates expensive objects, re-runs heavy selectors, or renders large navigation trees on every child navigation, users will feel it.
A few practical rules help:
- Memoize expensive derived values: If the sidebar builds a large nav model from permissions and feature flags,
useMemocan keep that work stable when unrelated child views change. - Stabilize props into shared components: If your header receives freshly created callback props every render,
React.memoon the header won't help much. - Keep layout components thin: The parent route should own structure and route-level context, not all page business logic.
What to optimize and what to leave alone
Don't turn every layout into a memoization experiment. Most dashboard shells are fast enough without special treatment. Optimize when a profiler or a clearly laggy interaction points to a real issue.
Use this decision frame:
- Optimize layout rendering when the shell contains heavy menus, expensive permission calculations, or persistent widgets.
- Don't optimize blindly when the layout mostly renders static markup and an
<Outlet />. - Split route code first if the main pain is initial load, not rerender cost.
If route changes feel slow, check bundle boundaries before you start wrapping everything in
React.memo.
The best nested route setups stay boring. One layout owns one shell, child routes are relative, and route transitions update only the content region users expect to change.
Testing Strategies and Migrating from v5
Nested routing is one of those features that benefits from integration tests more than isolated unit tests. You don't just want to know whether DashboardLayout renders. You want to know whether the layout stays visible while the correct child route appears inside it.
That's why route-aware tests are worth the effort. They protect the contract your users care about.
A meaningful test for nested layouts
With React Testing Library, mount the route tree and start at a nested URL:
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import { render, screen } from '@testing-library/react';
import DashboardLayout from './DashboardLayout';
import ReportsPage from './ReportsPage';
test('renders dashboard shell and reports content for nested route', () => {
render(
<MemoryRouter initialEntries={['/dashboard/reports']}>
<Routes>
<Route path="/dashboard" element={<DashboardLayout />}>
<Route path="reports" element={<ReportsPage />} />
</Route>
</Routes>
</MemoryRouter>
);
expect(screen.getByText('Workspace')).toBeInTheDocument();
expect(screen.getByText('Reports')).toBeInTheDocument();
});
This kind of test catches route regressions that isolated component tests miss. If someone removes the <Outlet />, changes a path incorrectly, or moves the child route outside the parent, the test fails for the right reason.
If your team also verifies full navigation flows in the browser, these notes on Cypress integration tests for routed applications pair well with route-level React Testing Library coverage.
The mental shift from v5 to v6 and later
Teams migrating from React Router v5 usually struggle less with syntax than with mindset. In v5, nested routing often felt more manual. In v6 and later, nested routes are central to the layout model.
Here's the quick comparison.
| Concept | React Router v5 Example | React Router v6+ Example |
|---|---|---|
| Route container | <Switch> |
<Routes> |
| Route render API | <Route path="/settings" component={Settings} /> |
<Route path="/settings" element={<Settings />} /> |
| Nested child matching | Often manual, with repeated path strings | Declared as children inside the parent route |
| Default child route | Usually another explicit route | index route inside the parent |
| Shared layout rendering | Commonly handled outside route tree or via wrappers | Built directly around <Outlet /> in parent layout |
Before and after
A simplified v5-style pattern often looked like this:
<Switch>
<Route path="/dashboard/reports" component={ReportsPage} />
<Route path="/dashboard/analytics" component={AnalyticsPage} />
<Route path="/dashboard" component={DashboardLayout} />
</Switch>
That structure makes layout composition awkward because the layout route competes with the child routes.
A v6+ approach is more direct:
<Routes>
<Route path="/dashboard" element={<DashboardLayout />}>
<Route index element={<DashboardHomePage />} />
<Route path="reports" element={<ReportsPage />} />
<Route path="analytics" element={<AnalyticsPage />} />
</Route>
</Routes>
This maps much better to the UI. The layout owns the shell. The children own the content area.
Migration advice that saves time
If you're moving an older dashboard forward, keep the migration mechanical at first:
- Replace
SwitchwithRoutes. - Convert
componentorrenderusage toelement. - Identify repeated layout wrappers across sibling pages.
- Pull that shared shell into a parent route component.
- Insert
<Outlet />where the child view should appear. - Convert child paths to relative values.
Don't try to redesign the whole app while migrating. First make the route tree reflect the current UI accurately. Then clean up deeper concerns like folder structure, lazy loading, or route protection.
If your team is modernizing a SaaS frontend while also tightening operations around AI and automation, MakeAutomation can help connect those pieces. They work with B2B teams on scalable systems, implementation support, and process design so product improvements don't get stuck in fragmented workflows.
