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).
What routing has to solve
It's not just URL → component. A routing layer answers:
- URL parsing and matching (params, query, splats).
- Code-splitting boundaries (which JS for which route).
- Data loading (when, where, parallel or serial).
- Layout nesting (shared chrome, nested children).
- Transitions (entering/leaving, pending states).
- Preloading (hover, viewport, predictive).
- Auth/permission gating.
- Scroll restoration and history management.
- Error boundaries per route.
- 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.
app/
layout.tsx
page.tsx
posts/
layout.tsx
page.tsx
[slug]/
page.tsxPros: convention, instant DX, easy to find files. Cons: less flexibility, configuration via filename magic.
Config-based (React Router): explicit route tree as data.
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:
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:
app/
dashboard/
layout.tsx <- sidebar + header, stays mounted
page.tsx <- dashboard home
settings/
page.tsx <- swaps in main area onlyWin: 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
| Decision | Options |
|---|---|
| Routing model | File-based vs config-based |
| Data loading | Render-then-fetch vs loader vs RSC |
| Splitting | Per-route vs per-feature |
| SSR | Off / on-demand SSR / SSG / RSC |
| State | URL as source of truth vs in-memory |
| Preloading | None / 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.