Back to System Design
System Design
hard
mid

How do you choose a routing strategy for a large frontend application?

Routing strategy covers: URL → view mapping, code-splitting boundaries, data fetching (waterfalls vs parallel vs streaming), nested layouts, transitions and preloading, auth-gating, and history/scroll restoration. Modern frameworks (Next App Router, Remix, TanStack Router) ship opinionated answers; vanilla React Router needs explicit choices for each. Key trade-off: file-based routing (convention, instant DX) vs config-based (flexibility, explicit).

8 min read·~5 min to think through

What routing has to solve

It's not just URL → component. A routing layer answers:

  1. URL parsing and matching (params, query, splats).
  2. Code-splitting boundaries (which JS for which route).
  3. Data loading (when, where, parallel or serial).
  4. Layout nesting (shared chrome, nested children).
  5. Transitions (entering/leaving, pending states).
  6. Preloading (hover, viewport, predictive).
  7. Auth/permission gating.
  8. Scroll restoration and history management.
  9. Error boundaries per route.
  10. SSR/SSG hand-off and hydration.

The strategy choices

1. File-based vs config-based

File-based (Next, Remix, TanStack Router, SvelteKit): folder structure = route tree.

ts
app/
  layout.tsx
  page.tsx
  posts/
    layout.tsx
    page.tsx
    [slug]/
      page.tsx

Pros: convention, instant DX, easy to find files. Cons: less flexibility, configuration via filename magic.

Config-based (React Router): explicit route tree as data.

jsx
const routes = [
  { path: '/', element: <Home />,
    children: [{ path: 'posts/:id', element: <Post /> }] }
];

Pros: full flexibility, programmatic. Cons: more boilerplate, easier to drift.

2. Data-loading strategy

The killer question: when does data fetch?

  • Render-then-fetch (vanilla React): components mount, useEffect fires, fetch starts. Causes waterfalls — child fetch waits for parent render.
  • Route loaders (Remix, TanStack Router, Next App Router): loader runs in parallel with route resolution, before render. Eliminates waterfalls.
  • Streaming SSR + Suspense: server starts streaming HTML, async chunks resolve as data arrives.

Loaders are the modern default. They turn N waterfall round-trips into 1 parallel batch.

3. Code splitting per route

Every route is its own chunk. Modern frameworks do this automatically; vanilla React Router uses lazy:

jsx
const Post = React.lazy(() => import('./Post'));

Bundle analyzer should show: tiny shared core + per-route chunks. If you see a 3MB main bundle, splitting failed.

4. Nested layouts

A layout wraps its child route, persisting across navigation:

ts
app/
  dashboard/
    layout.tsx     <- sidebar + header, stays mounted
    page.tsx       <- dashboard home
    settings/
      page.tsx     <- swaps in main area only

Win: no re-render of unchanged chrome on nav.

5. Preloading

Major INP / nav-speed lever:

  • Hover preload: on link hover, fetch the chunk + loader data.
  • Viewport preload: when a link enters the viewport (IntersectionObserver), prefetch.
  • Eager: on app load, prefetch the N most likely next routes.

Next <Link> and Remix <Link prefetch> ship this out of the box.

6. Pending states / transitions

While a route's loader runs, what shows?

  • React Router v6: useNavigation() returns pending state.
  • Next App Router: loading.tsx file is automatic.
  • Suspense boundary: shows fallback during async resolve.

Pattern: small inline indicator on the slow part, not a full-page spinner.

7. Auth gating

Two strategies:

  • Per-route loader rejects: loader throws / redirects if unauthed.
  • Wrapper component: <RequireAuth> checks and redirects.

Loader-based is server-friendly and avoids flicker. Wrapper is React-only.

8. Scroll restoration

  • On back navigation, restore scroll position.
  • On forward navigation to a new route, scroll to top.
  • Anchor links (#section) jump to anchor.

Built into Remix, Next, TanStack Router. React Router has <ScrollRestoration>.

Common architectural choices

DecisionOptions
Routing modelFile-based vs config-based
Data loadingRender-then-fetch vs loader vs RSC
SplittingPer-route vs per-feature
SSROff / on-demand SSR / SSG / RSC
StateURL as source of truth vs in-memory
PreloadingNone / hover / viewport / predictive

URL as source of truth

Big architectural call: do filters, sort, pagination, open-modal live in the URL or in component state?

  • URL: shareable, bookmarkable, survives reload, observable. Cost: more parsing.
  • State: simpler, no parsing. Cost: not shareable, lost on reload.

Most apps under-use the URL. Tabs, modals, filters all belong in query params for most products.

Modern picks

  • Next App Router: file-based, server components, route loaders, automatic splitting + prefetch. Heavy but covers everything.
  • Remix / React Router 7: file-based, loaders, less magic, framework-agnostic.
  • TanStack Router: type-safe, file or config, loaders.
  • React Router v6 (data router): lighter, no SSR by default.

Anti-patterns

  • Fetching in components instead of loaders — waterfalls.
  • One giant bundle, no splitting.
  • Modal/filter state not in URL — broken sharing.
  • Redirecting in component code instead of loader — flicker.
  • No prefetch on hover — every nav cold-loads.

Mental model

Routing is the architectural seam between URL, code, data, and UI. Decisions here propagate everywhere — splitting strategy controls bundle size, loader strategy controls perceived latency, URL strategy controls shareability. Pick a framework whose opinions match your needs and accept its conventions; routing is one place where convention beats freelance every time.

Follow-up questions

  • How do loader-based data fetching and Suspense interact?
  • When does URL-as-state break down?
  • What's the bundle impact of nested layouts?
  • How do you handle auth-gated routes without flicker?

Common mistakes

  • Fetching data in components — waterfalls instead of loader parallelism.
  • Filter/modal state outside the URL — broken share/reload.
  • No prefetch on Link — every nav cold-loads chunks.
  • Full-page spinner on every nav instead of granular pending UI.
  • Auth check in component → render flicker before redirect.

Performance considerations

  • Route-level splitting drops initial JS 50-80%. Loader-parallel data fetch saves multi-hundred-ms of waterfalls. Hover prefetch makes navigation feel instant. The combined effect: cold nav 1.5s → warm nav 100ms.

Edge cases

  • Race condition: user nav-clicks during in-flight loader — abort the old one.
  • Optimistic nav with rollback if loader rejects.
  • Catch-all routes (404) inside nested layouts.
  • Hash routing for legacy environments without history API.

Real-world examples

  • Next App Router — file-based + loaders + automatic prefetch in production.
  • Remix — popularized loader-based data fetching at the route level.
  • Stripe Dashboard — heavy use of nested layouts.
  • GitHub — URL-as-state for almost every filter and tab.

Senior engineer discussion

Seniors pick a routing framework deliberately and adopt its conventions wholesale. They push state into the URL by default, design loaders to eliminate waterfalls, and treat prefetching as a perf primitive. They are deeply suspicious of in-house routing layers — the surface area is huge and the off-the-shelf solutions are mature.

Related questions