Client-side Routing at Scale
System design for SPA routing at scale — code splitting, nested layouts, data loaders, auth gates, and perf.
- 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
- Central router config vs distributed packages
- Loaders vs component fetch — waterfalls vs flexibility
- URL state vs global store — shareability vs convenience
- 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.
Related on this site
- Auth Session Design for SPAs
- SSR CSR Islands Architecture
- Code Splitting Strategies
- System Design Interview Framework