Back to React
React
medium
mid

How would you show a fallback UI on a per route basis in a React app?

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

  1. 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.
  2. 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 routeReact.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 startTransition to 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.

Related questions