This page narrows a decision from the broader Choosing a Boundary Granularity Strategy guide, itself part of Error Boundary Architecture Fundamentals: should a single-page application catch render errors with one boundary at the root, or one boundary per route?
The exact failure this page solves #
A dashboard app wraps its entire component tree in a single ErrorBoundary at the root, right next to createRoot().render(...). A null-reference bug ships in the /invoices/:id detail view. The next time a user opens an invoice with a missing field, the boundary trips — and because it sits above the router, the navigation bar, the sidebar, and every other route go down with it. The user sees a generic “Something went wrong” screen with no way back to the dashboard except a full page reload, which also discards any in-progress work on unrelated tabs.
The fix is not “add more try/catch” — it’s choosing the right granularity for the boundary. A boundary placed too high has a blast radius of the whole application; a boundary placed correctly at the route level contains the crash to the one screen that failed, while the shell — nav, header, sidebar — stays mounted and interactive. This page works through the concrete decision criteria, then gives a complete hybrid implementation that keeps both a root safety net and per-route isolation.
Decision criteria #
Use these questions to decide where boundaries belong, rather than defaulting to “one at the top and call it done”:
- Can the shell itself fail independently of routed content? If your nav bar reads from a context provider or a store that can throw (e.g., a malformed user-permissions payload), a single root boundary is the only thing that can catch it — but it must not also own the routed content, or every route crash takes the shell down too.
- Do users need to escape a broken screen without reloading? If the answer is yes — and for any multi-route app it almost always is — the routed content needs its own boundary so the nav remains clickable after a crash.
- How many independent route trees do you have? A two-route marketing site gets little benefit from per-route isolation. A dashboard with a dozen semi-independent feature areas (invoices, settings, reports) benefits enormously, because a bug in one area should never take down the other eleven.
- Do you need route-level telemetry? A single root boundary’s
onErrorcallback only tells you “the app crashed somewhere.” A per-route boundary can tag every report with the exact pathname, which is often the difference between reproducing a bug in minutes versus days. - What is the wiring cost your team can absorb? A root-only boundary is one component. Per-route boundaries usually cost one additional wrapper component around the router’s
Outlet(orchildrenprop) — not one boundary per route file — so the marginal cost is small once the pattern is in place.
None of these criteria argue for per-route boundaries instead of a root boundary. They argue for both, layered, which is exactly the pattern most production React apps converge on once they’ve felt the pain of a single all-encompassing boundary. This layered approach is also the default recommendation in Best Practices for Global vs Local Error Boundaries in SPAs, which covers the broader propagation-strategy trade-offs beyond routing specifically.
Zero-to-working implementation #
The example below shows the complete shell: a thin root boundary outside the router-rendered content, and a per-route boundary wrapping the Outlet. The root boundary only fires for failures the shell itself produces; the per-route boundary is what actually contains day-to-day route crashes.
// AppShell.tsx
import { Outlet, useLocation } from 'react-router-dom';
import { ErrorBoundary } from 'react-error-boundary';
import { NavBar } from './NavBar';
import { RootCrashFallback } from './RootCrashFallback';
import { RouteCrashFallback } from './RouteCrashFallback';
import { reportBoundaryError } from './telemetry';
// Root boundary: last resort only. It exists to catch failures in the
// shell itself (NavBar, providers) — it must NOT wrap routed content,
// or a route crash would take the whole shell down with it.
export function App() {
return (
<ErrorBoundary FallbackComponent={RootCrashFallback}>
<AppShell />
</ErrorBoundary>
);
}
function AppShell() {
return (
<div className="app-shell">
<NavBar />
<main>
<PerRouteBoundary>
<Outlet /> {/* the active route renders here */}
</PerRouteBoundary>
</main>
</div>
);
}
// Per-route boundary: resets on every pathname change, so a crash on
// /invoices/42 does not linger once the user navigates elsewhere.
function PerRouteBoundary({ children }: { children: React.ReactNode }) {
const location = useLocation();
return (
<ErrorBoundary
FallbackComponent={RouteCrashFallback}
resetKeys={[location.pathname]}
onError={(error, info) =>
reportBoundaryError(error, {
route: location.pathname,
componentStack: info.componentStack,
})
}
>
{children}
</ErrorBoundary>
);
}
The nav bar and layout chrome are declared once, in AppShell, entirely outside PerRouteBoundary. That placement is the whole trick: React only unmounts the subtree that a boundary directly wraps, so anything rendered as a sibling of the boundary — not a child — survives a trip.
Step-by-step explanation #
-
Wrap the app in a thin root boundary.
ApprendersAppShellinside a singleErrorBoundary. This is the only boundary that can ever produce a fully blank screen, so keep its fallback simple and its scope minimal. -
Render the shell outside the per-route boundary.
NavBaris a sibling ofPerRouteBoundary, not a child of it. IfPerRouteBoundarytrips, React unmounts only what it wraps —Outletand everything beneath it — leavingNavBarmounted and responsive. -
Wrap the
OutletinPerRouteBoundary. Every route rendered through this router — regardless of how many routes you add later — inherits isolation automatically, because the boundary lives in the layout, not in each route’selement. -
Key the reset on
location.pathname.resetKeys={[location.pathname]}tells the boundary to clear its tripped state whenever the pathname changes. Without this, navigating away from a crashed route and back would still show the stale fallback, because error boundaries never reset themselves on their own — the mechanics of this reset are covered in more depth in Resetting Error Boundaries on Route Change in React Router. -
Tag telemetry with the route path in
onError. Passinglocation.pathnameintoreportBoundaryErroralongsideinfo.componentStackgives every crash report per-route granularity, so your dashboard can answer “which route is crashing” instead of just “the app crashed.” -
Verify the shell survives. Throw an error inside one routed component, confirm the nav bar is still clickable and other routes still render, then navigate to the crashed route again and confirm the boundary has reset rather than showing a stale fallback.
Root vs per-route: the comparison #
| Approach | Blast radius | Nav survives crash? | Wiring cost | Telemetry granularity |
|---|---|---|---|---|
| Single root boundary | Entire app | No — shell unmounts too | Minimal (one wrapper) | Coarse — app-level only |
| Per-route boundary only | One route’s subtree | Yes, for route crashes | Moderate (one wrapper around Outlet) |
Per-route path on every report |
| Hybrid (recommended) | Root: shell failures only; routes: contained per route | Yes, except for shell-level failures | Moderate (root + per-route reset) | Both app-level and per-route available |
The hybrid row is the one worth shipping: it costs one extra component over a per-route-only setup, and in exchange it removes the single failure mode — a shell-level crash — that per-route boundaries alone cannot catch.
Edge cases #
Nested layouts with their own sub-routes. If a route itself renders a nested <Outlet> (e.g., a settings section with tabs), a single PerRouteBoundary at the top level only isolates crashes to the whole settings section, not to an individual tab. Add a second, narrower boundary around the nested Outlet if tab-level isolation matters — this mirrors the same shell-outside-boundary pattern one level deeper, and is covered from the isolation angle in Component Isolation Techniques.
Missing resetKeys. Omitting resetKeys (or an equivalent manual resetErrorBoundary() call tied to navigation) means the boundary stays tripped indefinitely — even after the user navigates to a route that would otherwise render fine — until a full page reload remounts the tree.
Lazy-loaded route chunks. A React.lazy() chunk that fails to load (a stale deploy, a network blip) throws inside the same Outlet subtree and is caught by the same per-route boundary. Distinguish this case in your fallback by checking the error’s message or a ChunkLoadError name, and offer a “reload” action rather than the generic route-crash message, since retrying rarely fixes a genuinely stale chunk reference.
A crash in the shell itself. If NavBar throws — say, a malformed permissions payload from a context provider — only the root boundary can catch it, and the resulting fallback necessarily replaces the entire shell. This is the one scenario the hybrid pattern cannot fully avoid; keeping the root fallback simple (a reload prompt, not a full recovery flow) is the correct trade-off rather than trying to eliminate it, and it’s also the moment a well-designed fallback matters most — see Fallback UI Rendering Patterns for how to keep that screen from looking broken.
Verification steps #
-
Route-level crash test. Throw an error inside one route’s render (e.g.,
if (!data) throw new Error('boom')), navigate to it, and confirm in the rendered DOM that the nav bar and layout chrome are still present and interactive — only the<main>content area shows the route fallback. -
Reset-on-navigate check. From the crashed route, click a nav link to a different route, then navigate back to the originally crashed route (assuming the underlying bug is fixed or the bad data is gone). Confirm the route renders normally rather than showing a stale fallback — this validates the
resetKeyswiring. -
Shell-level crash test. Temporarily throw inside
NavBarto confirm the root boundary — not the per-route boundary — catches it, and that the resulting fallback replaces the whole page as expected. -
Telemetry payload check. Inspect the network request (or console log, in development) fired from
reportBoundaryErrorand confirm it includes the correctroutefield matching the pathname where the crash occurred, not the previous route. -
React DevTools Profiler. Confirm that tripping the per-route boundary only unmounts the routed subtree — the nav bar’s component instance should not appear as remounted in the Profiler’s commit list.
Frequently asked questions #
Can I skip the root boundary entirely if every route already has its own boundary?
No. Errors thrown by the router itself, a context provider rendered above the Outlet, or the navigation shell happen outside any per-route boundary’s subtree and would otherwise produce a fully blank page with no error handling at all. A thin root boundary is cheap insurance against exactly that class of failure, even though it should rarely fire in practice.
Does the per-route boundary have to live inside each route’s element, or in the layout?
Put it in the shared layout component that renders the Outlet, not in every individual route’s element. Wrapping the layout once means every current and future route automatically inherits isolation without any per-route boilerplate, and it keeps the reset logic — keyed on location.pathname — in exactly one place.
Why does navigating back to a crashed route sometimes still show the fallback UI?
React error boundaries do not reset themselves on re-render; once tripped, a boundary stays tripped until it unmounts or is explicitly reset. If resetKeys isn’t tied to something that changes on navigation — like the pathname — the boundary has no signal that it should try rendering its children again, so it keeps showing the fallback even after the underlying problem is gone.
Related #
- Choosing a Boundary Granularity Strategy — parent guide covering the broader granularity trade-offs beyond routing, including component-level and feature-level boundaries
- Best Practices for Global vs Local Error Boundaries in SPAs — the wider propagation-strategy context this routing decision fits into
- Resetting Error Boundaries on Route Change in React Router — a deeper look at the
resetKeysmechanics used in the hybrid implementation above