Diagnosing React Router v6 Nested Routes That Render Empty Outlets
A diagnostic guide for React Router v6 nested routes where parent components render but child routes stay invisible — covering missing Outlet, route config structure, path splats, conditional rendering traps, and escalation steps.
30 Apr 2026, 17:24 UTC

The Symptom: Parent Renders, Children Don't
You navigate to /dashboard/settings. The Dashboard layout component appears — sidebar, header, everything — but the settings panel never shows. No console errors, no network failures, just empty space where the child route should be. This is the classic <Outlet /> mismatch in React Router v6.
Quick Cause Reference
| Observed Behavior | Most Likely Cause | Where to Look |
|---|---|---|
| Parent renders, child route matches but nothing appears | Missing <Outlet /> in parent JSX | Parent component render output |
| Child renders on some paths but not others | <Outlet /> wrapped in conditional/early return | Parent component control flow |
| Route config loads but children never match | Parent route missing children array | Route configuration object |
| Child path works when absolute, fails when relative | Parent path lacks splat (*) or param | Parent route path property |
Nested <Routes> in sidebar/component | Independent matching context created | Component tree for stray <Routes> |
Ordered Diagnostic Checks
1. Verify <Outlet /> Exists in Render Output
Open React DevTools → Components tab. Select your parent route component (e.g., Dashboard). In the right pane, inspect the rendered JSX. You must see <Outlet /> as a child element. If it's absent, the router has nowhere to inject the child.
// ❌ Missing outlet
function Dashboard() {
return (
<div className="layout">
<Sidebar />
<main>Content here</main>
</div>
);
}
// ✅ Outlet present
function Dashboard() {
return (
<div className="layout">
<Sidebar />
<main><Outlet /></main>
</div>
);
}
2. Check Route Configuration Structure
Parent routes must declare a children array containing child route objects. Flat route arrays won't nest.
// ❌ Flat — children never match
const routes = [
{ path: 'dashboard', element: <Dashboard /> },
{ path: 'dashboard/settings', element: <Settings /> }
];
// ✅ Nested — children array on parent
const routes = [
{
path: 'dashboard/*',
element: <Dashboard />,
children: [
{ path: 'settings', element: <Settings /> },
{ path: 'profile', element: <Profile /> }
]
}
];
3. Confirm Parent Path Ends With Splat
In v6, a parent route matching /dashboard must use dashboard/* (or dashboard/:param*) to delegate remaining segments to children. Without the splat, /dashboard/settings won't match the parent at all.
// ❌ Parent consumes exact path only
{ path: 'dashboard', element: <Dashboard />, children: [...] }
// ✅ Splat delegates remainder
{ path: 'dashboard/*', element: <Dashboard />, children: [...] }
4. Ensure Child Paths Are Relative
Child route path values must be relative to the parent. Do not repeat the parent segment.
// ❌ Absolute — breaks nesting
children: [
{ path: '/dashboard/settings', element: <Settings /> }
]
// ✅ Relative — correct
children: [
{ path: 'settings', element: <Settings /> }
]
5. Hunt for Conditional Rendering Around <Outlet />
Early returns, ternaries, or error boundaries that swallow the outlet prevent child injection. The outlet must render unconditionally whenever the parent matches.
// ❌ Outlet inside conditional
function Dashboard({ user }) {
if (!user) return <LoginPrompt />;
return <main><Outlet /></main>;
}
// ✅ Outlet always renders; gate content inside children
function Dashboard() {
return (
<main>
<Outlet />
</main>
);
}
// Child routes handle auth via their own loaders or elements
6. Scan for Stray <Routes> Instances
A <Routes> component creates a new matching context. If you render <Routes> inside a sidebar, modal, or any non-route-element component, its children match independently — they won't appear in the parent's outlet.
// ❌ Sidebar creates isolated matching context
function Sidebar() {
return (
<Routes>
<Route path="settings" element=<SettingsLink /> />
</Routes>
);
}
// ✅ Use Link/NavLink for navigation, not nested Routes
function Sidebar() {
return (
<nav>
<Link to="settings">Settings</Link>
</nav>
);
}
Fixes Mapped to Findings
| Finding | Fix | Code Change |
|---|---|---|
No <Outlet /> in parent |
Add <Outlet /> where children should appear |
<main><Outlet /></main> |
<Outlet /> inside conditional |
Move outlet outside condition; gate data in child loaders | Remove early return wrapping outlet |
| Flat route config | Restructure to children array on parent route object |
See nested example in Check #2 |
Parent path missing * |
Change path: 'dashboard' → path: 'dashboard/*' |
path: 'dashboard/*' |
| Child paths absolute | Make relative: '/dashboard/settings' → 'settings' |
path: 'settings' |
Stray <Routes> |
Replace with <Link> or lift routes to single <Routes> |
Remove nested <Routes> |
Escalation: When Checks Pass But Children Still Don't Render
- Minimal reproduction. Strip to one parent + one child in a fresh
App.jsx. If it works, the bug is in your app's composition.// Minimal test — paste into fresh App.jsx import { BrowserRouter, Routes, Route, Outlet } from 'react-router-dom'; function Parent() { return <div>Parent<Outlet /></div>; } function Child() { return <div>Child</div>; } export default function App() { return ( <BrowserRouter> <Routes> <Route path="parent/*" element={<Parent />}> <Route path="child" element={<Child />} /> </Route> </Routes> </BrowserRouter> ); } - Check route order. More specific routes must precede generic ones. A catch-all
path="*"before your nested route will swallow matches. - Verify single router instance. Multiple
<BrowserRouter>components (e.g., in micro-frontends or test wrappers) create isolated histories. Runnpm ls react-router react-router-dom— duplicates cause silent matching failures. - Data API silent failures. If using v6.4+ loaders/actions, a thrown error without a route
errorElementor rootErrorBoundarycan swallow the render. AdderrorElementto the parent route or wrap the router.<Route path="dashboard/*" element={<Dashboard />} errorElement={<DashboardError />} children={[ { path: 'settings', element: <Settings />, loader: settingsLoader } ]} /> - Escalate with reproduction. If v6.20+ and all above verified, file a GitHub issue with the minimal reproduction from step 1.
Verification Checklist
- Navigate to child route — child UI appears inside parent layout
- React DevTools Profiler: record navigation → parent re-renders with
Outletshowing child in component tree - Console logs in parent render body and (if using function form)
<Outlet>{child => console.log(child)}</Outlet>confirm render sequence - Network tab: no 404 for child route (404 = config mismatch, not render issue)
npm ls react-router react-router-domshows single version
Common Pitfalls to Avoid
- Pathless layout routes (no
pathprop) still require<Outlet />but don't consume URL segments. Don't confuse them with splat routes. - TypeScript route typing. In v6.20+,
RouteObjectchildren require explicit generic typing. Missing types can hide config errors at compile time.// Explicit typing catches missing children const routes: RouteObject[] = [ { path: 'dashboard/*', element: <Dashboard />, children: [ { path: 'settings', element: <Settings /> } ] } ]; - Loader errors without ErrorBoundary. A loader throwing without a route-level
errorElementor root boundary produces no UI — just an empty outlet.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.