ESC

Type to search the knowledge base.

Client-side Routing at Scale

System design for SPA routing at scale — code splitting, nested layouts, data loaders, auth gates, and perf.

intermediate4 min read
  • system-design
  • interview
  • architecture
  • routing

Scope the problem

In scope:

  • Route configuration for large multi-team apps
  • Nested layouts and outlet composition
  • Lazy route modules and prefetch
  • Auth/role gates
  • Data loading patterns tied to navigation
  • Scroll, focus, and deep linking

Out of scope: DNS, CDN POP design, native navigation controllers (unless hybrid).

Assumptions: React-like SPA; may have SSR entry; hundreds of routes.

Requirements & metrics

Type Examples
Functional deep links, nested URLs, back/forward, 404
Non-functional fast route transitions; small initial graph
DX type-safe paths; team ownership boundaries
A11y focus on navigation; titles

High-level architecture

┌────────────────────────────────────────────────────────┐
│ Router (history / data APIs)                           │
├──────────────┬───────────────────┬─────────────────────┤
│ Route modules│ Layout shells     │ Guards / middleware │
│ (lazy)       │ app, settings…    │ auth, roles, flags  │
├──────────────┴───────────────────┴─────────────────────┤
│ Data layer: loaders / React Query keyed by route params│
└────────────────────────────────────────────────────────┘

Route graph design

// conceptual config owned per package
const routes = [
  {
    path: "/",
    layout: AppShell,
    children: [
      { index: true, lazy: () => import("./home") },
      {
        path: "settings",
        layout: SettingsLayout,
        guard: requireAuth,
        children: [
          { path: "profile", lazy: () => import("./settings/profile") },
          { path: "billing", lazy: () => import("./settings/billing") },
        ],
      },
      {
        path: "admin",
        guard: requireRole("admin"),
        lazy: () => import("./admin"),
      },
    ],
  },
  { path: "*", lazy: () => import("./not-found") },
];

Ownership: each product area exports a route subtree; shell composes them. Avoid one mega-routes file that every team conflicts on.

URL design

  • Hierarchical resources: /projects/:id/tasks/:taskId
  • Query for filters/search (?q=&page=) — shareable state
  • Avoid overloading hash unless required for legacy

Code splitting & prefetch

Technique When
React.lazy / import() per route default
Prefetch on link hover/focus high-probability navigations
Prefetch next step in wizards known flow
Parallel layout + page chunks nested routes
// link hover
function onIntent(path: string) {
  void import(/* webpackChunkName: "..." */ routeModuleMap[path]);
}

Budget: initial bundle includes shell + landing route only; settings/admin never on critical path.

Data loading strategies

1) Render-then-fetch (classic SPA)

  • Simple; waterfalls common
  • Use skeletons; parallelize queries

2) Route loaders (framework data APIs)

  • Fetch starts with navigation
  • Error/404 boundaries per route
  • Caching still needed for back-nav

3) Shared cache (React Query / SWR)

  • Key by params; stale-while-revalidate
  • Prefetch in loader or link intent
queryKey: ["project", projectId]
// invalidate on mutation; keep previous data on param change when UX wants

Avoid: fetching in every leaf without coordination → duplicate calls. Centralize keys.

Guards & middleware

Order matters:

navigation request
  → auth check
  → feature flag / entitlement
  → load data
  → render
  • Unauthenticated → login with returnUrl
  • Unauthorized → 403 page (not infinite redirect)
  • Feature off → 404 or upgrade page (product choice; don’t leak existence if security-sensitive)

Nested layouts

AppShell (nav, auth)
  └── SettingsLayout (side nav)
        └── ProfilePage

State that survives child changes lives in layout (e.g. settings nav). Force remount with key={param} when identity changes and residual state is wrong.

UX details that signal seniority

Concern Approach
Scroll restoration save scroll per history entry; scrollTo(0) on push
Focus focus main heading on route change
Titles route meta → document.title
Pending UI top progress bar on slow navigations
Blocked nav useBlocker for dirty forms
404 dedicated boundary

Performance

  • Route-based splitting is the primary lever
  • Don’t put huge providers above the entire tree if only one section needs them
  • Prefetch carefully — don’t thrash mobile networks
  • Measure: TTI of landing; INP on navigations; chunk sizes per route

SSR / hybrid note

With SSR, the first navigation is server; subsequent client transitions use the same route graph. Hydration mismatches often come from window-only route decisions — keep first paint deterministic.

Tradeoffs

  1. Central router config vs distributed packages
  2. Loaders vs component fetch — waterfalls vs flexibility
  3. URL state vs global store — shareability vs convenience
  4. MPA multi-page for some marketing routes vs full SPA

Interview close

Draw shell + nested layouts, lazy modules, guard order, and data cache keys. Call out focus/scroll and prefetch. Mention multi-team route ownership so the design scales organizationally, not just technically.

Further reading