Frontend
medium
mid
How would you show fallback UI per route
Wrap each route in its own Suspense boundary (loading fallback) and error boundary (error fallback). Use route-level skeletons matching the page layout, and a shared layout that stays mounted while the route content suspends.
6 min read·~12 min to think through
Per-route fallback UI means each page handles its own loading and error states, so a slow or broken route doesn't blank the whole app.
Two kinds of fallback, two boundaries
- Loading fallback → Suspense. Wrap the route's content in
<Suspense fallback={<RouteSkeleton />}>. While the route's code chunk or data is loading, the skeleton shows. - Error fallback → Error Boundary. Wrap the route in an error boundary so a thrown error renders an error page for that route, not a white screen everywhere.
Structure
jsx
<Routes>
<Route element={<AppLayout />}> {/* stays mounted: nav, sidebar */}
<Route path="/dashboard" element={
<RouteBoundary fallback={<DashboardSkeleton />} error={<RouteError />}>
<Dashboard />
</RouteBoundary>
} />
<Route path="/orders" element={
<RouteBoundary fallback={<OrdersSkeleton />} error={<RouteError />}>
<Orders />
</RouteBoundary>
} />
</Route>
</Routes>RouteBoundary = an ErrorBoundary wrapping a <Suspense>. Modern routers (React Router data APIs, Next.js App Router with loading.tsx / error.tsx) give this per-route out of the box.
Key design points
- Persistent layout — the shared shell (nav, header) stays mounted; only the route outlet suspends. The app never fully blanks.
- Skeletons should match the real layout — same approximate shape/size as the loaded page → minimal layout shift, perceived speed.
- Code-split per route —
React.lazy/ dynamic import per route so each chunk loads on demand; Suspense handles the wait. - Granularity — you can also nest boundaries within a route (e.g. a slow widget gets its own Suspense) so the rest of the page renders immediately.
- Error recovery — the error fallback should offer "Retry" (re-mount the boundary) and a route back to safety.
- Avoid fallback flicker — for fast loads, a tiny delay before showing the skeleton, or
startTransitionto keep the old UI during navigation.
Why per-route
Isolation: one route's failure or slowness is contained. Tailored UX: each page's skeleton/error matches its content. Performance: route-level code splitting keeps the initial bundle small.
Follow-up questions
- •Why wrap each route in its own boundary instead of one app-level boundary?
- •How do you avoid skeleton flicker on fast loads?
- •How does the persistent layout stay mounted while the route suspends?
- •How do Next.js loading.tsx / error.tsx map to this pattern?
Common mistakes
- •One app-level Suspense/error boundary, so any route blanks or breaks the whole app.
- •Generic spinners instead of layout-matched skeletons, causing layout shift.
- •Not code-splitting routes, so per-route Suspense buys little.
- •Error fallback with no retry or escape path.
Performance considerations
- •Per-route code splitting keeps the initial bundle small. Layout-matched skeletons reduce CLS and improve perceived performance. startTransition keeps the old page visible during navigation instead of flashing a fallback.
Edge cases
- •Fast loads causing a skeleton flash — needs a delay or transition.
- •Nested routes each needing their own boundary.
- •Errors thrown during the loading state.
- •Navigation away while a route is still suspending.
Real-world examples
- •Next.js App Router loading.tsx + error.tsx per route segment.
- •React Router data routes with errorElement and Suspense per route.
Senior engineer discussion
Seniors pair Suspense (loading) and error boundaries (failure) per route, keep a persistent layout so the app never fully blanks, and push for layout-matched skeletons over spinners. They discuss boundary granularity (route-level vs widget-level), flicker avoidance with startTransition, and map it onto the router's built-in primitives.