Client-side routing
How single-page apps change screens without reloading: matching URLs to nested layouts, loading each route's code and data in parallel, guarding private pages, and restoring scroll and focus.
Why apps need a router
Picture a project tracker. It starts with three screens: a list of projects, one project's board, and a settings page. The first version picks a screen with a switch on location.pathname inside App. It works, and it fits on one screen of code.
Two years later the app has 60 screens. Every project page shares a sidebar, every settings page shares a tab bar, and some pages need the user to be an admin. The switch has grown into 400 lines.
Each screen fetches its own data in a useEffect, so a project's issue page waits for the project, then for the issue, then for the comments. The whole app ships as one 2 MB bundle. Clicking Back lands you at the top of a long list you had scrolled halfway down.
None of these bugs is about one screen. They are about the space between screens: which code runs when the URL changes, in what order, and what is kept. That space is what a router owns.
What a router owns
A client-side router turns a URL into a screen without asking the server for a new HTML document. The basics, intercepting link clicks and calling history.pushState, are covered in the CSR and SPA vs MPA lessons. This lesson is about the architecture on top: the jobs a modern router does for every navigation.
- Match. Find which routes the URL belongs to, from the outer layout down to the page, and pull out the parameters, such as
projectId: "42". - Load code. Download the JavaScript for any matched route that is not loaded yet, so each screen ships only when someone visits it.
- Guard. Decide whether this user may see this route, and if not, redirect, for example to
/login?next=/projects/42. - Load data. Start every matched route's data request at the same moment, before any component renders.
- Render. Keep the layouts that did not change mounted, and swap only the parts that did.
- Recover. Catch errors and missing records in the nearest route that can show a useful message, while the rest of the page keeps working.
- Restore. Put scroll position and focus where the user expects: the top for a new page, the old spot after Back.
React Router, Next.js, TanStack Router, Vue Router, SvelteKit, and Angular's router all do these seven jobs. Their APIs differ, but once you know the jobs you can read any of them, and you can describe a routing layer in an interview without naming a library.
The mental model: a tree of routes
The one picture to keep in your head: routes form a tree, and a URL selects one branch of it. The root route is the app shell. Its children are the big areas of the app. Their children are the screens inside each area. The deeper you go, the more specific the screen.
When the URL is /projects/42/issues/7, the router does not find one route. It finds a chain of four: the root, the projects area, the project with id 42, and the issue with id 7. This chain is called the match (React Router calls it the matches array, Next.js calls each step a segment). Every job from chapter 1 runs over this chain: load code for each, load data for each, render each inside the one above.
projectId and issueId.Layouts and outlets
Each route in the chain renders a component. A parent route's component is a layout: it draws the frame (a sidebar, a header, a tab bar) and leaves a hole where its child goes. That hole is called an outlet. In React Router it is the Outlet component; in Next.js it is the children prop of layout.tsx; in Vue Router it is RouterView.
Think of picture frames inside picture frames. The outer frame is the app shell with the top bar; inside it sits the project frame with the project sidebar, and inside that sits the issue view. Moving from issue 7 to issue 8 swaps only the innermost picture, and the frames stay on the wall.
import { Outlet, NavLink } from "react-router";
export default function ProjectLayout() {
return (
<div className="project">
<nav aria-label="Project">
<NavLink to="issues">Issues</NavLink>
<NavLink to="board">Board</NavLink>
<NavLink to="settings">Settings</NavLink>
</nav>
<main>
<Outlet />
</main>
</div>
);
}
Three kinds of route that are easy to miss
- Index route. The child that renders when the URL stops at the parent.
/projects/42has no extra segment, so the project's index route shows, often an overview. Without one, the outlet is empty. - Layout route without a path. A route that wraps children in a frame or a guard but adds nothing to the URL. React Router calls it a pathless layout route. Next.js uses a route group, a folder in parentheses such as
(dashboard), that groups files without adding a segment. - Catch-all route. A route whose last segment matches everything left, such as
*in React Router or[...slug]in Next.js. It powers docs sites with deep paths and the final not-found page.
Check your understanding · Exercise 1
The route tree is: root, then projects with children (index) and :projectId, and :projectId has children (index), issues/:issueId, and settings. Which statements about the URL /projects/42/settings are true? Select all that apply.
Writing the tree down: config, files, or typed code
Every router needs the tree written down somewhere. There are three common ways, and the choice shapes how a team works for years: how you find the code for a URL, how code splitting happens, and how much the type checker can catch.
Config routing: the tree as data
The tree is a plain object or array in one file. Each entry has a path, a component, and optionally a loader, an error element, and children. React Router's data mode (createBrowserRouter), Vue Router, and Angular all start here. React Router 7's framework mode uses a routes.ts file in the same spirit, pointing each route at a module file.
import { type RouteConfig, index, layout, route } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
route("login", "routes/login.tsx"),
layout("routes/app-shell.tsx", [
route("projects", "routes/projects.tsx"),
route("projects/:projectId", "routes/project.tsx", [
index("routes/project-overview.tsx"),
route("issues/:issueId", "routes/issue.tsx"),
route("settings", "routes/project-settings.tsx"),
]),
]),
] satisfies RouteConfig;
The strength is that the whole map fits on one screen. You can see every URL the app answers, add a guard to a branch by wrapping it in a layout, and move a URL without moving files. The weakness is that the file becomes a merge-conflict hot spot when 10 teams add routes every week.
File-system routing: the tree as folders
The folder structure is the tree. Next.js App Router, SvelteKit, Nuxt, Remix's flat routes, and TanStack Router's file mode work this way. In Next.js a folder is a segment, [projectId] is a dynamic segment, and special file names give each segment its parts.
app/
layout.tsx root shell
login/page.tsx /login
(app)/ route group: no URL segment
layout.tsx signed-in shell with top bar
projects/
page.tsx /projects
[projectId]/
layout.tsx project sidebar
page.tsx /projects/42
loading.tsx skeleton while the segment loads
error.tsx catches errors below this folder
issues/[issueId]/page.tsx /projects/42/issues/7
settings/page.tsx /projects/42/settings
The strength is that the URL tells you where the code lives, and adding a page never touches a shared file, so teams rarely conflict. The framework can also split code per folder for free. The weakness is that renaming a URL means moving folders, and conventions such as brackets, parentheses, and @slot folders have to be learned.
Type-safe code routing
TanStack Router builds the tree in TypeScript, either written by hand or generated from files into a routeTree.gen.ts file. Because the compiler knows the full tree, it knows every valid path, every parameter, and the shape of every search parameter. A link to a route that does not exist, or a missing projectId, is a type error, not a 404 in production.
// src/routes/projects.$projectId.issues.$issueId.tsx
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/projects/$projectId/issues/$issueId")({
loader: ({ params }) => fetchIssue(params.projectId, params.issueId),
component: IssuePage,
});
function IssuePage() {
const issue = Route.useLoaderData();
return <h1>{issue.title}</h1>;
}
typedRoutes.Decision How should a new, medium-sized product app define its routes?
What we pick: If you already use a framework, follow its convention: Next.js file routes for server-heavy sites, React Router framework mode (a routes.ts config) or TanStack Start (typed file routes) for app-like products. In a plain client-rendered React app, a config file with lazy routes is the simplest start; pick TanStack Router when search params carry a lot of state and type safety matters to you.
Matching a URL and ranking the candidates
Matching sounds simple until two routes both fit. Take /projects/new. It fits the static route projects/new (the create form) and also the dynamic route projects/:projectId (with projectId equal to "new"). Which one wins decides whether the user sees a form or a "project not found" error.
The kinds of segment
A path is split on / into segments, and each route segment is one of a few kinds. The names differ by router; the ideas do not.
| Kind | React Router | Next.js | TanStack Router | Matches |
|---|---|---|---|---|
| Static | settings | settings/ | settings | Exactly that text |
| Dynamic | :projectId | [projectId]/ | $projectId | Any one segment |
| Optional | :lang? | [[...slug]]/ (catch-all only) | {-$lang} | One segment or nothing |
| Splat or catch-all | * | [...slug]/ | $ | Everything that is left |
First match wins, or most specific wins
Older routers, such as Express on the server and React Router 5's Switch, tried routes in the order you wrote them and took the first that fit. That puts the burden on you: if projects/:projectId is listed before projects/new, the form is unreachable. Teams kept comments like "keep this above that" in their route files.
Modern routers rank every candidate and pick the most specific, whatever the order. React Router scores each route: a static segment is worth much more than a dynamic one, an index route gets a small bonus, and a splat costs points. Next.js, Vue Router 4, and TanStack Router follow the same rule of thumb: static beats dynamic, dynamic beats catch-all. So /projects/new reaches the form, and /projects/42 reaches the project.
3 of 8 routes fit. /projects/new wins with 24 points.
Try /projects/new, /projects/42/issues/7, and /docs/guides/setup in the lab above. Notice that several routes can fit, that the scores separate them, and that the catch-all only wins when nothing more specific does.
Details that cause real bugs
- Trailing slashes.
/projects/and/projectsusually match the same route, but relative links resolve differently from each. Pick one form and redirect the other on the server, or you get two URLs for one page in analytics and search results. - Case. React Router, Vue Router, and TanStack Router match without regard to case by default (each has a
caseSensitiveorsensitiveoption). Next.js, and often your server and CDN, do not. Lowercase URLs everywhere avoid the question. - Encoding. In React Router, a segment
caf%C3%A9arrives as the paramcafé. Building links by string concatenation with unencoded user input breaks on/,?, and#; use the router's link helpers orencodeURIComponent. - Reserved words as ids. If ids can be any string, a user can create a project called
new. Use ids that cannot collide with static segments, such as numbers or slugs with a prefix. - Unknown URLs. A catch-all at the root, or the framework's not-found page, must answer every URL nothing else matched. On the server it must also send status
404, not200, which chapter 9 comes back to.
Check your understanding · Exercise 2
A router that ranks by specificity has the routes files/*, files/:fileId, files/shared, and files/:fileId/history. Which pairs of URL and winning route are right? Select all that apply.
Nested layouts: what stays and what re-renders
Nesting is not only a way to share markup. It decides what survives a navigation. When the URL changes, the router compares the old match with the new one, route by route from the root.
Routes that appear in both, with the same params, are left alone: they stay mounted and do not load again. From the first difference down, routes load their data and render again.
Go from /projects/42/issues/7 to /projects/42/settings. Root, projects, and :projectId (still 42) are shared, so the top bar, the project sidebar, and anything the user typed into a sidebar filter stay as they are. Only the outlet inside the project layout changes, from the issue to the settings page.
Why keeping layouts mounted matters
- Speed. React does not rebuild the sidebar's 200 links on every click. Less work on the main thread means a better INP, the Core Web Vital for how fast the page responds to input.
- State. A video in a layout keeps playing, a half-typed search keeps its text, a scrolled sidebar keeps its position.
- Fewer requests. A layout's data, such as the project name, does not need to be fetched again when only the child changes. React Router skips a parent's loader if its params and the search string did not change; Next.js calls this partial rendering and only renders the segments below the change on the server.
The same route with new params
A subtle case: going from issue 7 to issue 8. The matched route is the same (issues/:issueId), only the param changed. React sees the same component type in the same place, so it keeps the component instance and its state. If the issue page holds a draft comment in useState, the draft written for issue 7 now sits under issue 8.
// Fix 1: a key per issue makes React mount a fresh component.
function IssueRoute() {
const { issueId } = useParams();
return <IssuePage key={issueId} issueId={issueId!} />;
}
// Fix 2: keep drafts in a store keyed by issue id, so each issue has its own.
const draft = useDraftStore((s) => s.drafts[issueId] ?? "");
Layout variants frameworks add
- Remount on every navigation. Next.js has
template.tsx, which wraps like a layout but mounts fresh each time, for enter animations or per-page effects. - Parallel routes. Next.js
@slotfolders let one layout render two independent outlets, say a feed and a notifications panel, each with its own loading and error state. - Intercepting routes. Clicking a photo in a feed opens it as a modal over the feed at
/photos/9, while loading/photos/9directly shows a full page. Next.js supports this with folders like(.)photos; in React Router you get the same effect with the locationstateand a background location.
Check your understanding · Exercise 3
A user goes from /projects/42/issues/7 to /projects/43/issues/7. The project layout loads the project name; the issue page loads the issue. What happens in a router that keeps shared routes mounted? Select all that apply.
Data loaders and actions per route
Most slow navigations are not slow servers. They are waterfalls: requests that could run at the same time but run one after another, because each one starts only after the component that needs it has rendered.
The fetch-on-render waterfall
Here is the issue page from chapter 1, built the classic way. The project layout renders, its useEffect asks for the project (200 ms), and it shows a spinner instead of its outlet. When the project arrives, the outlet renders the issue page, whose effect asks for the issue (250 ms).
When the issue arrives, the comments panel renders and asks for comments (150 ms). The user waits 600 ms, and none of the three requests needed the others' answers.
The router can do better because it knows something the components do not: the whole match, before anything renders. From /projects/42/issues/7 it already knows that the project layout needs project 42 and the issue page needs issue 7. So it can start every route's request at the same moment. The wait becomes the slowest request, 250 ms, not the sum.
Loaders
A loader is a function attached to a route that returns the data that route needs. The router calls the loaders of all matched routes in parallel as soon as a navigation starts, waits for them, then renders. React Router and TanStack Router call it loader; SvelteKit calls it load. In the Next.js App Router the same idea appears as async server components: layouts and pages in the match render in parallel on the server, each awaiting its own data.
import { Form, useNavigation } from "react-router";
import type { Route } from "./+types/issue";
export async function loader({ params, request }: Route.LoaderArgs) {
const issue = await api.getIssue(params.issueId, { signal: request.signal });
if (!issue) throw new Response("Not found", { status: 404 });
return { issue, comments: api.getComments(params.issueId) }; // not awaited: streams later
}
export async function action({ params, request }: Route.ActionArgs) {
const form = await request.formData();
await api.addComment(params.issueId, String(form.get("body")));
return { ok: true }; // the router then re-runs the loaders on this page
}
export default function Issue({ loaderData }: Route.ComponentProps) {
const busy = useNavigation().state === "submitting";
return (
<Form method="post">
<h1>{loaderData.issue.title}</h1>
<textarea name="body" />
<button disabled={busy}>Comment</button>
</Form>
);
}
Four details that make loaders work well
- Await only what blocks the first paint. The issue title is needed before anything is useful; the comments are not. Return the comments as a promise and let a
Suspenseboundary stream them in. The page shows at 250 ms and comments fill in when ready. - Cancel the losers. If the user clicks a second link while the first navigation is loading, the router aborts the first one. React Router passes
request.signal; pass it tofetchso the network request stops too. This also prevents the stale-response race from the request races lesson. - Watch for hidden waterfalls. If the issue loader first awaits the project to learn its
orgId, you are back to a chain. Put what children need in the URL, let the API accept the URL's ids, or share the parent's request through a cache so both await the same promise. - Cache, or every visit refetches. React Router loaders do not cache by themselves; pair them with a server-state library and call its
ensureQueryDatainside the loader. TanStack Router has a built-in cache with astaleTimeper route. Then hover prefetching, from the prefetching lesson, can warm the loader before the click.
Actions and revalidation
An action is the write side: a function on the route that receives a form submission. React Router routes a Form with method="post" to the action of the route it belongs to; Next.js uses server actions. The pattern that makes this simple is what happens after: the router re-runs the loaders on the page, so every part of the screen shows the new data without hand-written cache updates.
That default is safe but can be wasteful. Adding a comment does not change the project name, yet the project loader runs again. React Router lets a route opt out with shouldRevalidate; libraries with a cache let you invalidate only the keys that changed. Start with the safe default and trim once a loader is expensive.
First load of /projects/42/issues/7. Kept: nothing. Fetched: user 0 to 120 ms, project 120 to 320 ms, issue 320 to 570 ms. Each waited for its parent, ready at 570 ms.
In the lab, switch between fetching in components and route loaders, then navigate between the four URLs. Watch three things: which layouts stay mounted, whether the bars on the timeline run side by side or one after another, and which error boundary catches a failing loader when you turn the failure on.
Check your understanding · Exercise 4
A dashboard route has a layout loader (user, 120 ms) and a page loader (report, 400 ms). The report endpoint needs the user's team id, so the page loader awaits the layout's user first. Which changes remove the waterfall? Select all that apply.
Code splitting by route, without a new waterfall
Routes are the natural seams of an app: a user on the issue page does not need the code for billing settings. Splitting one bundle per route is the first split almost every team makes. The code splitting lesson covers bundlers, chunks, and dynamic import(). This chapter is about what the router has to get right when route code arrives late.
Lazy routes
File-system frameworks split per route for you: every page.tsx in Next.js is its own chunk. In a config router you mark a route as lazy, and the router calls the import function when the route first matches.
// A: everything in one lazy module
{ path: "issues/:issueId", lazy: () => import("./routes/issue") }
// B: loader in the main bundle, only the component is lazy
import { issueLoader } from "./routes/issue.loader";
{
path: "issues/:issueId",
loader: issueLoader,
lazy: async () => ({ Component: (await import("./routes/issue.view")).default }),
}
The code-then-data trap
Version A looks tidy, but it hides a chain. The loader lives inside the lazy module, so the router must download that module before it can even start the request for data. With 180 ms for the chunk and 250 ms for the data, the user waits 430 ms.
Version B keeps the loader, a few hundred bytes, in the main bundle. On navigation the router starts the data request and the chunk download at the same moment, and the user waits 250 ms. TanStack Router builds this split in: a route's loader is critical and stays in the main bundle, while the component goes to a .lazy.tsx file or is split out automatically. Recent React Router versions accept an object for lazy, one import per property, for the same reason.
Nested routes load their chunks together
When a deep link matches four lazy routes, a good router imports all four at once, because the match is known up front. Do not undo that by importing a child route's code from inside the parent component; that brings back a chain of chunks, each found only after the last one ran.
After a deploy: missing chunks
A user opens the app at 9:00. At 9:30 you deploy, and the new build has new chunk names. At 9:45 the user clicks to a route they have not visited, and the router asks for issue.3f9a1c.js, a file from the old build. If your server deleted it, the import fails and the screen breaks.
- Keep old assets on the CDN for a few days after a deploy. Hashed file names never clash, so this is cheap and fixes most cases.
- Recover on failure. Catch the failed import (Vite fires a
vite:preloadErrorevent; others throw a chunk load error) and do a full page load of the target URL, which fetches the new HTML and new chunk names. - Do not loop. Mark the reload in
sessionStorageso a chunk that is truly missing shows an error page instead of reloading forever.
Check your understanding · Exercise 5
A route's chunk takes 300 ms to download and its data 200 ms. The loader is inside the lazy chunk. Which statements are true? Select all that apply.
Protected routes and redirects
A protected route is one only some users may see: signed-in users, admins, members of this project. The common first version is a component that checks a flag and redirects. It works in a demo and has three problems in production.
function RequireAuth({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const location = useLocation();
if (loading) return <Spinner />;
if (!user) return <Navigate to="/login" replace state={{ from: location }} />;
return children;
}
- It is not security. Everything in the browser can be changed by the user. Flip
userin the devtools and the guard opens. The data is only safe if the server refuses to send it. - It runs late. The page HTML and the route's code have already been requested before this component renders. In a router with loaders, every matched loader has usually run, and fired its API calls, before this guard gets a chance to redirect.
- It flashes. Until
useAuthresolves, the user sees a spinner or, worse, a moment of the protected layout before being bounced to the login page.
Check in three places, each for a different reason
- At the edge or server, before the page. Next.js middleware (renamed
proxyin Next.js 16), an edge function, or the server's route handler looks at the session cookie and answers with a redirect before any HTML or JavaScript is sent. Keep it cheap: is there a valid session at all? - In the route, before render. A layout route's loader, a server component, or TanStack Router's
beforeLoadchecks the finer rule, such as "is this user a member of project 42?", and throws a redirect or a 403 or 404. TanStack'sbeforeLoadruns from parent to child before any loader, so a failed check stops the child loaders too. In React Router, loaders run in parallel, so a child's loader can still fire while the parent's loader redirects; React Router 7 middleware runs before them if you need a check that blocks. - In the API, always. Every endpoint checks the session and permission for the exact record it returns. This is the real security boundary. The other two layers are there for speed and a clean user experience.
Redirecting back after sign-in
When a signed-out user opens /projects/42/issues/7, send them to /login?next=%2Fprojects%2F42%2Fissues%2F7 and, after sign-in, back to where they were going. Three details matter.
- Validate
next. Accept only a path on your own site: it starts with one/, not//, not/\, and not a full URL. Otherwise?next=https://evil.exampleturns your login page into an open redirect that phishing emails can use. - Replace, do not push. The redirect to the login page and the redirect back should replace the history entry. Otherwise Back from the issue lands on the login page, which redirects forward again, trapping the user.
- Keep work on expiry. If a session runs out while the user is typing, a 401 from the API should save the draft, sign in again (in a dialog or a new tab), and retry, not throw the user to the login page and lose the text.
403 or 404?
When a signed-in user opens a project they are not a member of, you can say "you do not have access" (403) or "not found" (404). A 403 is friendlier inside a company, where people request access. A 404 hides that the project exists at all, which matters for private repositories or documents whose names are sensitive. GitHub, for example, shows a 404 for a private repository you cannot see.
Decision Where should the check for "signed in and a member of this project" live?
What we pick: Use the layered version for anything with real private data. A client-only guard is acceptable only as a user-experience layer over an API that already enforces every rule.
Check your understanding · Exercise 6
A signed-out user opens /admin/users. The app checks the session in edge middleware and in each API endpoint, and the login page reads a next param. Which choices are right? Select all that apply.
Scroll restoration and error boundaries
Two jobs from chapter 1 are about what happens around the screen rather than on it: putting the scroll position where the user expects, and keeping a failure in one part of the page from taking down the rest.
Why the browser's own scroll restore fails in an app
On a classic site, the browser remembers how far down each page you were. Press Back and it reloads the old page, or takes it from the back/forward cache, and scrolls to the old spot. The setting behind this is history.scrollRestoration, which is "auto" by default.
In a single-page app, Back fires a popstate event and the router renders the old screen itself, often after a loader runs. The browser tries to restore scroll right away, when the long list is not on the page yet, so the page is too short and the user lands near the top. Routers fix this by setting history.scrollRestoration = "manual" and doing the job themselves.
The rules a router follows
- Before leaving a page, save
window.scrollYunder the current history entry's key, usually insessionStorageso it survives a reload. - On a new navigation (a push), scroll to the top, or to the element named by the
#hashif there is one. - On Back or Forward (a pop), wait until the screen has rendered with its data, then restore the saved position for that entry.
- On a small change such as a filter, tab, or sort written to the search params, keep the position. React Router has
preventScrollReseton links and forms, and Next.js Link hasscroll={false}.
history.scrollRestoration to manual and runs this itself, so the restore happens after the screen has its full height.React Router does this with its ScrollRestoration component, Next.js App Router scrolls new pages into view by default, and TanStack Router has a scrollRestoration option. Loaders make it reliable: because data is ready before render, the page has its full height when the router restores the position.
Moving focus to the new page's heading after a navigation, so screen reader users know something changed, is the other half of this job. The focus management lesson covers it.
Error boundaries per route
Any loader can fail: the API times out, the record was deleted, the user lost access. Each route can declare an error boundary, a component that renders in the route's place when something below it throws. React Router uses an ErrorBoundary export or errorElement; Next.js uses error.tsx per folder; TanStack Router uses errorComponent.
An error bubbles up to the nearest route with a boundary, and only that route's outlet is replaced. If the comments loader fails and the issue route has a boundary, the user still has the top bar and the project sidebar, and can click somewhere else. Without boundaries below the root, one failed request blanks the whole app.
- Put boundaries where the user can still act. Usually at each layout that has navigation, and at the page level for the main content.
- Not-found is its own kind. A loader that finds no issue should throw a 404 (
throw new Response("Not found", { status: 404 })in React Router,notFound()in Next.js), so the boundary shows "Issue 7 does not exist" rather than a generic error. - Send the real status. With server rendering, a missing record must produce an HTTP
404, or search engines index error pages as content. A purely client-rendered app cannot set the status; the CSR lesson covers the workarounds for these soft 404s. - Know your framework's edges. In Next.js,
error.tsxin a folder catches errors in that folder's page and children, but not in the same folder'slayout.tsx; those go to the parent's boundary. The root layout needsglobal-error.tsx.
Check your understanding · Exercise 7
Routes: root (has a boundary), app shell (no boundary), project layout (has a boundary), issue page (no boundary). The issue loader throws a 500. What does the user see? Select all that apply.
Routing at scale, and in the interview
A router with 10 routes and one team needs little more than chapters 2 to 9. With 300 routes and 12 teams, two more things decide whether routing stays healthy: links that cannot break, and clear ownership of each part of the tree.
Typed links and params
A link written as a string, "/projects/" + id + "/isues", fails silently: the typo becomes a 404 that someone finds weeks later. When a route moves, every string pointing at it has to be found by search. Typed routing turns both into compile errors.
<Link to="/projects/$projectId/issues/$issueId" params={{ projectId: "42", issueId: "7" }}>
Open issue
</Link>
export const Route = createFileRoute("/projects/$projectId/issues")({
validateSearch: z.object({
status: z.enum(["open", "closed"]).catch("open"),
page: z.number().int().min(1).catch(1),
}),
});
// In the component, Route.useSearch() is typed as { status: "open" | "closed"; page: number }
- TanStack Router types paths, params, loader data, and search params from one route tree, which makes it a strong fit when the URL holds a lot of state (see the URL as state lesson).
- React Router 7 in framework mode generates a
+typesfile per route, so loader data and params are typed in each module, and has anhrefhelper that checks a path and its params. - Next.js has a
typedRoutesoption, stable since 15.5, that type-checks thehrefofLinkagainst the app's route files.
Types check shape, not content. A param is still a string a user can edit. Parse projectId in the loader, and answer a 404 for abc rather than letting NaN reach the API.
Route-level code ownership
Routes are a good unit of ownership because they are a good unit of everything else: a route has its own URL, its own chunk, its own loader and action, its own error boundary, and its own metrics. Large codebases make that explicit.
| Practice | What it looks like | Why |
|---|---|---|
| Co-locate by route | The route folder holds its component, loader, action, tests, and styles | One place to change a screen; deleting a route deletes everything it used |
| Owners per folder | CODEOWNERS maps app/(app)/billing/ to the billing team | Reviews go to the people who know the screen |
| Shared layouts are platform code | The app shell and root layout belong to a platform team | A change there re-renders every route; it needs wider review |
| Budgets per route | Each route's chunk has a size limit in CI | One team's heavy library does not slow other teams' pages |
| Metrics by route pattern | Report /projects/:projectId, not /projects/42 | Millions of distinct URLs would make dashboards useless |
This is also where routing meets the modular frontend and micro-frontends lessons: when teams split an app into independently deployed parts, the split almost always falls on route boundaries, with the shell owning the top of the tree.
Using it in a system design interview
Bring routing up in any question with more than one screen: an email client, a project tracker, a dashboard, a store. Do it right after the requirements, while you sketch the screens, because the route tree is the skeleton the rest of the design hangs on.
- Draw the tree. Four to eight routes with their URLs: shell, the main areas, the detail screens. Mark which are layouts.
- Mark data per route. Next to each route, the one or two requests its loader makes. Say that they run in parallel on navigation.
- Mark boundaries. Where errors are caught, where the auth check happens, where a skeleton shows while loading.
- Say what is in the URL. Ids in the path, filters and tabs in search params, so links can be shared and Back works.
- Name the extras if time allows. Route splitting with prefetch on hover, scroll restoration on Back, focus on the new heading.
| Level | What a strong answer shows |
|---|---|
| Junior | A route list with URLs, a shared layout, a redirect for signed-out users, and a 404 page |
| Mid | A nested tree with outlets, loaders per route that run in parallel, a code split per route, error boundaries per layout |
| Senior | Server-side guards with the API as the boundary, the code-then-data trap, revalidation after actions, scroll for inner panes, typed links, and route ownership across teams |
Check your understanding · Exercise 8
You are designing an email client. Which routing decisions would an interviewer expect from a strong mid-level answer? Select all that apply.
Summary
- A router owns seven jobs on every navigation: match, load code, guard, load data, render, recover from errors, and restore scroll and focus.
- Routes form a tree and a URL selects a branch; each route renders inside its parent's outlet, and routes shared by the old and new URL stay mounted.
- Modern routers rank matches by specificity, so static segments beat dynamic ones and catch-alls come last, whatever the order you wrote them in.
- Loaders let the router start every route's data request at once; the wait becomes the slowest request instead of the sum. Watch for loaders that await each other.
- Split code by route, but keep loaders out of the lazy chunk so code and data download together, and handle missing chunks after a deploy.
- Guards in the browser are user experience; check at the edge or server for speed and in the API for security, and validate redirect targets.
- Give each layout an error boundary and send real 404 statuses, so one failed request leaves the rest of the app usable.
- At scale, typed links and route-level ownership keep hundreds of routes and many teams from breaking each other.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
Walk me through what your router does between a link click and the new screen.
It matches the URL against the route tree and gets a chain of routes, from the shell down to the page, with their params. It then compares that chain with the current one: routes that are the same with the same params stay mounted.
For the routes that changed, it starts three things at once: downloading any lazy route chunks, running guards, and calling every loader in parallel, each with an abort signal. If a guard fails, it redirects. When the critical data is ready, it renders the new routes into their parents' outlets, streams slower data behind Suspense, updates the history entry, scrolls to the top or the hash, and moves focus to the new heading.
Question 2
Your issue page takes 900 ms to show after a click, but each API call is under 300 ms. What do you look for?
A waterfall. I would open the network panel and check whether requests start one after another. Typical causes: each layout fetching in useEffect after it renders, a child loader awaiting its parent's data, or a lazy route whose loader lives inside the lazy chunk, so the data waits for the code.
Fixes in order: move fetching into route loaders so they start together, remove dependencies between loaders by putting ids in the URL or letting the API resolve them, keep loaders out of lazy chunks, and prefetch the chunk and loader on hover. The target is a wait equal to the slowest single request.
Question 3
How do you protect an admin section in a React app?
In layers. The API checks the session and role on every request; that is the real security. In front of it, an edge or server check redirects signed-out users before any page is sent, and the admin layout's loader or beforeLoad checks the role and answers 403 or 404. A client-side guard is only for user experience.
On redirect I keep where the user was going in a next param, accept only same-site paths to avoid an open redirect, and replace the history entry so Back does not bounce through the login page.
Question 4
Users complain that Back takes them to the top of a long list. Why, and how do you fix it?
In a single-page app the browser restores scroll immediately on popstate, before the router has rendered the list, so the page is too short. The router should set history.scrollRestoration to manual, save the position per history entry before leaving, and restore it after the screen renders with its data.
Loaders help because the list has its full height on first render. If the list scrolls inside a pane rather than the window, or is virtualized, save the pane's offset or the first visible index instead of window.scrollY.
Question 5
Config routes or file-system routes for a large app with many teams?
File-system routes, or typed routes generated from files, usually win at that size: the URL tells you where the code is, adding a route never touches a shared file, each folder can have its own owners in CODEOWNERS, and the framework splits code per route for free.
Config routes are clearer for small apps or when URLs must change without moving files. Either way I would want typed links, so renaming a route breaks the build rather than production.
Practice it
These design problems lean on client-side routing.
References
- React Router: Route module Loaders, actions, error boundaries, and revalidation per route.
- Next.js: Layouts and pages File-system routing, nested layouts, and dynamic segments in the App Router.
- Next.js: Error handling How error.tsx and not-found boundaries nest by segment.
- TanStack Router: Type safety Typed paths, params, and search params from one route tree.
- TanStack Router: Data loading Parallel loaders, beforeLoad, and the built-in cache.
- TanStack Router: Code splitting Why the loader stays in the main bundle while the component is split.
- React Router: ScrollRestoration Saving and restoring scroll per history entry.
- MDN: History.scrollRestoration The browser setting routers switch to manual.
- Next.js security advisory for CVE-2025-29927 Why middleware must not be the only auth check.
Read next
- Single-page vs multi-page appsWhether each click loads a new document from the server or the app swaps views inside one page: a choice that shapes first load, navigation speed, memory over long sessions, the back button, and accessibility.
- Client-side renderingThe server sends an almost empty HTML page plus a JavaScript bundle, and the browser builds the whole page itself: easy to host and great behind a login, but slow to first content and weak for search.
- The URL as stateFilters, sort, page, tab, and selected item stored in the address bar instead of only in memory, so a view can be shared, bookmarked, refreshed, and walked back with the Back button: the cheapest state store the browser gives you.
- Code splitting and bundlingBreaking one big JavaScript bundle into smaller pieces that load only when needed, because every kilobyte of JavaScript costs download, parse, and run time on the user's device.
- Prefetching and preloading dataStarting the fetch for the data and code of the next screen before the user asks for it, on hover, on sight, or on a guess, so the click feels instant without wasting the user's data plan.
- Focus managementDeciding where keyboard focus goes when the page changes under the user (route changes, modals, deletes, new content) so keyboard and screen reader users never lose their place.