cf.completefrontend
My progress
FUNDAMENTALS · MODULE 17

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.

  1. 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".
  2. Load code. Download the JavaScript for any matched route that is not loaded yet, so each screen ships only when someone visits it.
  3. Guard. Decide whether this user may see this route, and if not, redirect, for example to /login?next=/projects/42.
  4. Load data. Start every matched route's data request at the same moment, before any component renders.
  5. Render. Keep the layouts that did not change mounted, and swap only the parts that did.
  6. Recover. Catch errors and missing records in the nearest route that can show a useful message, while the rest of the page keeps working.
  7. 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 seven jobs of a router for one navigation, top to bottom. First, match the URL to routes and params. Then three jobs start together: load code for lazy routes, run guards that may redirect, and call every loader at once. Then render, keeping shared layouts mounted. Last, recover from errors at the nearest boundary, and restore scroll and focus.1. Matchroutes + paramsstarted together2. Load codelazy route chunks3. Guardmay redirect4. Load dataevery loader at once5. Renderkeep shared layouts6. Recovernearest error boundary7. Restorescroll and focus
Matching comes first because every other job needs the list of routes. The middle three run side by side; doing them one after another is the most common reason navigations feel slow.

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.

A route tree. The root has three children: login, projects, and account. Projects has an index child and a :projectId child. :projectId has three children: an index, issues/:issueId, and settings. For the URL /projects/42/issues/7 the matched branch is root, projects, :projectId, and issues/:issueId, with params projectId 42 and issueId 7.rootloginprojectsaccount(index):projectId(index)issues/:issueIdsettingsURL: /projects/42/issues/7match: root, projects, :projectId, issues/:issueIdparams: projectId = "42", issueId = "7"
A URL selects one branch, from the root to a leaf. Each route on the branch renders inside the one above it, and each dynamic segment adds a param: here 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.

tsx
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>
  );
}
A layout route in React Router: it renders its frame and an outlet for whichever child matched.
A browser window at /projects/42/issues/7. The outer frame is the app shell with the top bar. Inside it, the project layout draws a sidebar with Overview, Issues, Board, and Settings, with Issues selected. Its outlet holds the issue page for issue 7, and inside the issue page a comments panel that streams in later./projects/42/issues/7app shell: top bar, search, avatarproject layoutOverviewIssuesBoardSettingsissue page, in the outlet#7 Login fails on Safaricomments: stream in later
Three frames, three routes. Clicking Board swaps only the right-hand area; the top bar and the sidebar stay mounted and keep their state.

Three kinds of route that are easy to miss

  • Index route. The child that renders when the URL stops at the parent. /projects/42 has 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.

ts
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;
React Router 7 framework mode: app/routes.ts lists the tree; each entry points at a route module.

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.

text
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 same tree in the Next.js App Router. Special files attach a layout, a loading state, and error handling to each segment.

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.

tsx
// 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>;
}
A TanStack Router file route. The path string is checked against the generated tree, and params are typed.
A matrix of three ways to define routes against five properties. Config file: whole map in one file yes, URL points to the file some, new route without a shared file no, split per route for free some (only when a framework reads the config), typos in links fail the build no. File system: no, yes, yes, yes, some. Typed routes: some, yes, yes, yes, yes.config filefile systemtyped routeswhole map in one fileyesnosomeURL points to the filesomeyesyesnew route, no shared filenoyesyessplit per route for freesomeyesyestypos in links fail buildnosomeyes
Config files win on overview; file-system and typed routes win as teams grow. File-system routing gets type-checked links only with an option such as Next.js typedRoutes.

Decision How should a new, medium-sized product app define its routes?

Config file

One routes.ts or createBrowserRouter call lists the whole tree.

Each entry points at a component or a lazily loaded module.

Pros

  • Whole map visible in one place
  • URLs can change without moving files
  • Works in any bundler

Cons

  • Shared file conflicts as teams grow
  • Code splitting is your job unless the framework does it

File-system routes

Folders and file names define segments.

The framework builds the tree and the bundles at build time.

Pros

  • URL to file is obvious
  • Per-route bundles and server rendering come free
  • Teams own folders

Cons

  • Renames move folders
  • Naming conventions to learn
  • Tied to one framework

Type-safe code routes

Routes are typed objects, often generated from files.

Links, params, and search params are checked by the compiler.

Pros

  • Broken links fail the build
  • Typed, validated search params
  • Refactors are safe

Cons

  • A code generation step
  • Heavier types can slow the editor in very large trees

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.

Segment kinds in three routers. Parameters arrive as strings, or as an array of strings for a Next.js catch-all.
KindReact RouterNext.jsTanStack RouterMatches
Staticsettingssettings/settingsExactly that text
Dynamic:projectId[projectId]/$projectIdAny 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.

Try
Matching /projects/new against eight routes. /: does not fit. /projects: does not fit. /projects/new: fits, score 24. /projects/:projectId: fits, score 17. /projects/:projectId/issues/:issueId: does not fit. /projects/:projectId/settings: does not fit. /docs/*: does not fit. *: fits, score 1. The winner is /projects/new.routescore/no fit/projectsno fit/projects/new24wins/projects/:projectId17fits, loses/projects/:projectId/issues/:issueIdno fit/projects/:projectId/settingsno fit/docs/*no fit*1fits, loses
Every route that fits gets a score, using React Router's rules: 10 for each static segment, 3 for each dynamic one, and minus 2 for a splat. The highest score wins, whatever order the routes were written in.

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 /projects usually 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 caseSensitive or sensitive option). Next.js, and often your server and CDN, do not. Lowercase URLs everywhere avoid the question.
  • Encoding. In React Router, a segment caf%C3%A9 arrives as the param café. Building links by string concatenation with unencoded user input breaks on /, ?, and #; use the router's link helpers or encodeURIComponent.
  • 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, not 200, 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.

Two panels for the same click, from an issue to project settings. Left, one flat route per page: the top bar, the sidebar, and the settings page all remount, and the sidebar filter is lost. Right, nested layouts: the top bar and sidebar are kept, and only the outlet changes to the settings page.one route per pagetop barsidebarsettingseverything remountssidebar filter is lostnested layoutstop barsidebarsettingsonly the outlet changessidebar keeps its state
With nesting, the router compares the old and new match and replaces only from the first route that differs. Everything above stays mounted, state included.

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.

tsx
// 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] ?? "");
Two fixes for state that leaks between params: reset it with a key, or keep it outside the component.

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 @slot folders 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/9 directly shows a full page. Next.js supports this with folders like (.)photos; in React Router you get the same effect with the location state and 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.

A timeline of the three requests for /projects/42/issues/7. Fetching in each component: the project from 0 to 200 ms, the issue from 200 to 450 ms, the comments from 450 to 600 ms, so the screen is ready at 600 ms. With route loaders all three start at 0: project ends at 200, issue at 250, comments at 150, and the screen is ready at 250 ms.fetch in each componentready at 600 msproject200 msissue250 mscomments150 msroute loadersready at 250 msproject200 msissue250 mscomments150 ms0200400600milliseconds after the click
Same requests, same server. Components can only fetch after they render, so each waits for its parent; loaders start together because the router knows the whole match before rendering.

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.

tsx
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>
  );
}
A React Router 7 route module: the loader runs before render, the action handles the form, and the component reads typed data.

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 Suspense boundary 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 to fetch so 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 ensureQueryData inside the loader. TanStack Router has a built-in cache with a staleTime per 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.

Go to
Data is fetched
Make fail
Router lab. 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.matched routesready at 570 msroot + top barfirst render, boundary120 msproject 42first render, boundary200 msissue 7first render250 ms0200400600
Left: the matched routes, from the root down, and what happened to each. Right: when each route's data request ran, on the same clock.

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.

ts
// 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 }),
}
Two ways to make a route lazy in React Router's data mode. The second keeps the loader out of the late chunk.

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.

A bar chart of the wait after a click to a lazy route, drawn to scale, with a 180 ms chunk and 250 ms of data. Loader inside the lazy chunk: 180 ms of code, then 250 ms of data, 430 ms in total. Loader in the main bundle: code and data start together, 250 ms. Chunk and loader prefetched on hover: both already done at the click, about 30 ms to render.loader inside the lazy chunk430 mscodethen dataloader in the main bundle250 mscode loads alongsidechunk and loader prefetched on hoverabout 30 ms: render only
Moving the loader out of the lazy chunk turns a sum into a maximum. Prefetching on hover moves the remaining wait to before the click.

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:preloadError event; 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 sessionStorage so 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.

tsx
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;
}
A client-only guard. Fine for user experience, useless as security.
  • It is not security. Everything in the browser can be changed by the user. Flip user in 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 useAuth resolves, 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

  1. At the edge or server, before the page. Next.js middleware (renamed proxy in 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?
  2. In the route, before render. A layout route's loader, a server component, or TanStack Router's beforeLoad checks the finer rule, such as "is this user a member of project 42?", and throws a redirect or a 403 or 404. TanStack's beforeLoad runs 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.
  3. 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.
A sequence between the browser, the edge, the route loader, and the API. The browser requests /projects/42. The edge checks the session cookie and passes the request on. The route loader asks the API whether the user is a member of project 42. The API answers 403, no. The loader answers the browser with a not-found page and status 404. If the user were signed out, the edge would have answered with a redirect to /login at the second step. The API checks the session again on every call.BrowserEdgeRoute loaderAPIGET /projects/42session okmember of 42?403: no404 pagesigned out? the edge redirects to /login at step 2and every API call checks the session again
Each layer checks what it can check cheaply: the edge the session, the route the membership, the API every record. Only the API check is security; the others make the answer fast and clear.

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.example turns 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?

Client component guard only

A wrapper reads auth state and renders a redirect.

Pros

  • Easy to write
  • Works in a static, client-only app

Cons

  • Not security
  • Flashes
  • Loaders may already have fired

Edge or middleware only

Check the cookie before serving any route under /projects.

Pros

  • No flash
  • Stops work early and cheaply

Cons

  • Usually cannot check per-record membership
  • One bypass exposes everything

Edge for session, route for membership, API for every record

Edge redirects signed-out users.

The project layout's loader or beforeLoad checks membership.

The API enforces both again.

Pros

  • Fast redirects and clear errors
  • Secure even if a front layer is skipped

Cons

  • The rule lives in more than one place; share one function or service for it

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.scrollY under the current history entry's key, usually in sessionStorage so it survives a reload.
  • On a new navigation (a push), scroll to the top, or to the element named by the #hash if 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 preventScrollReset on links and forms, and Next.js Link has scroll={false}.
A state machine for scroll restoration. While reading, a navigation starts, and the router saves the current scroll position under the history entry's key. Then it picks one of four outcomes: go to the top for a new link, go to the element for a link with a hash, restore the saved position for Back or Forward, or keep the position for a filter, tab, or sort change. After the new screen renders with its data, the user is reading again.readingnavigation startssave positionsessionStorage, by entry keygo to topnew link (push)go to #hashlink with #idrestore yBack or Forwardkeep yfilter, tab, sortafter render with data: reading again
The router sets 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.

Four nested routes: root with an error boundary, app shell with none, project layout with an error boundary, and the issue page whose loader throws a 500. The issue page has no boundary, so the error bubbles up one level to the project layout's boundary. What the user sees: the app shell's top bar still there, and the project boundary's error message in place of the project layout.roothas error boundaryapp shellno boundaryproject layouthas error boundaryissue pageloader throws 500bubblesupuser seestop barCould not loadthis project
The error replaces the nearest route that has a boundary, and everything above it keeps working. A boundary on the issue route itself would also keep the project sidebar.
  • 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.tsx in a folder catches errors in that folder's page and children, but not in the same folder's layout.tsx; those go to the parent's boundary. The root layout needs global-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.

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.

tsx
<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 checks the path, requires every param, and validates search params with a schema.
  • 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 +types file per route, so loader data and params are typed in each module, and has an href helper that checks a path and its params.
  • Next.js has a typedRoutes option, stable since 15.5, that type-checks the href of Link against 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.

Treating the route as the unit of ownership.
PracticeWhat it looks likeWhy
Co-locate by routeThe route folder holds its component, loader, action, tests, and stylesOne place to change a screen; deleting a route deletes everything it used
Owners per folderCODEOWNERS maps app/(app)/billing/ to the billing teamReviews go to the people who know the screen
Shared layouts are platform codeThe app shell and root layout belong to a platform teamA change there re-renders every route; it needs wider review
Budgets per routeEach route's chunk has a size limit in CIOne team's heavy library does not slow other teams' pages
Metrics by route patternReport /projects/:projectId, not /projects/42Millions 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.

  1. Draw the tree. Four to eight routes with their URLs: shell, the main areas, the detail screens. Mark which are layouts.
  2. Mark data per route. Next to each route, the one or two requests its loader makes. Say that they run in parallel on navigation.
  3. Mark boundaries. Where errors are caught, where the auth check happens, where a skeleton shows while loading.
  4. Say what is in the URL. Ids in the path, filters and tabs in search params, so links can be shared and Back works.
  5. Name the extras if time allows. Route splitting with prefetch on hover, scroll restoration on Back, focus on the new heading.
LevelWhat a strong answer shows
JuniorA route list with URLs, a shared layout, a redirect for signed-out users, and a 404 page
MidA nested tree with outlets, loaders per route that run in parallel, a code split per route, error boundaries per layout
SeniorServer-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.

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?

Question 3

How do you protect an admin section in a React app?

Question 4

Users complain that Back takes them to the top of a long list. Why, and how do you fix it?

Question 5

Config routes or file-system routes for a large app with many teams?

Practice it

These design problems lean on client-side routing.

References

Read next