Client-Side Routing
Intercept the link, match the URL, swap the view — and inherit every job the browser was quietly doing during a real navigation.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
What does a client router actually replace, and what does the browser stop doing for me the moment I intercept a link?
Someone clicks a link, pastes a URL into a chat, or presses back. They expect the address bar and the screen to agree, and they expect the page they arrive at to be the page they were promised.
Listen for clicks on anchors, call event.preventDefault(), push the new path with history.pushState, and render whatever component the path maps to. Nothing reloads, so navigation is instant and the transition can be animated. This is obvious because it is true: the swap really is faster, and the state really does survive.
Cmd-click stops opening a new tab, middle-click stops working, and "open in new window" from the context menu lands on a blank page — because the handler intercepted a click the browser was supposed to own.
- Cmd-click stops opening a new tab, middle-click stops working, and "open in new window" from the context menu lands on a blank page — because the handler intercepted a click the browser was supposed to own.
- A screen-reader user activates a link, the view changes, and nothing is announced. Focus is still on an anchor that has just been removed from the DOM, so the next
Tabstarts from the top of the document with no explanation (Focus Management). - The previous page's
fetchresolves after the new view has mounted and writes its data into it. The user sees an order detail page populated with the previous order's line items (Out-of-Order Responses). - Back goes to the right URL but the wrong scroll position — usually the top, sometimes wherever the new page happened to leave the scroller (Scroll Restoration).
- The tab spinner never appears, so a navigation that is waiting on a slow request is indistinguishable from a click that did not register. The user clicks again.
document.titlestill says the previous page. So does theog:metadata a share sheet reads, and so does anything a bookmark or a screen reader takes from it (The Head: Metadata That Changes Rendering).
What is actually happening
In the browser, not in the framework.
- A real navigation unloads the current document and creates a new one. Every JavaScript value, timer, listener, module instance and in-flight request belongs to the old document and dies with it. That destruction is the reason a real navigation is so hard to get subtly wrong.
- A client-side navigation does none of that. The document, its script context and its memory are the same before and after; the only things that changed are the URL in the address bar, a history entry, and a subtree of the DOM (The DOM Is Not Your HTML).
- The address bar is changed by
history.pushStateorhistory.replaceState, which write a session history entry without issuing a request. The browser trusts you: it does not verify that the URL you pushed describes anything (History and Navigation). - The router then matches the new URL against a route table, resolves whatever that route needs, and renders. Matching is a pure function of the URL, which is exactly what makes a URL restorable (Route Matching).
- Because the URL is written by you rather than derived from a request, it is only as truthful as your code. A router that renders a view without updating the URL, or updates the URL without re-rendering, has produced a page that cannot be shared, bookmarked or reloaded — the two halves disagree.
- This is the claim the module rests on: the URL is application state. Not a label for state; the state itself, in the one place a user can copy, send, bookmark and restore (The URL Is Application State).
What this makes the browser do
And which of it is avoidable.
- Almost none of the work of a real navigation. No DNS, no connection, no HTML parse, no fresh CSSOM, no re-executed bundle. That saving is the entire value proposition (What Happens When You Open a Website).
- Instead: a DOM subtree teardown and rebuild, a style recalculation over the new nodes, a layout of whatever changed size, and paint plus composite for the changed area (The Cost of a Change).
- Whatever the destination route asks for over the network — which is now sequenced by your code rather than by the browser, and therefore can be a waterfall you built by accident (Reading a Network Waterfall).
- Retained work you did not intend: listeners, observers, timers and cached responses from every view visited since the tab opened. Nothing unloads them for you (Memory Leaks).
- Avoidable work: re-mounting a persistent shell — nav, sidebar, header — on every navigation, when only the outlet needed to change (Route Loading Boundaries).
URL, router, match, view
The shape of a client router is four boxes, and the direction of the arrows is the whole idea. The URL is upstream of everything: it is read to produce a match, the match is read to produce a view. Nothing downstream is allowed to be the source of truth, because nothing downstream survives a reload.
That is what "the URL is application state" means operationally. Not that URLs are nice to have, but that the render is a pure function of the URL plus the data that URL identifies. If you can construct the current screen from the address bar alone, the screen is shareable, bookmarkable, restorable and testable. If you cannot, you have state that only exists because of the sequence of clicks that produced it — and no user can send that to anyone.
The second arrow, from the view back to the URL, is the one that gets forgotten. When a user opens a detail panel, changes a filter or switches a tab, that is a state change worth recording; if it does not write back to the URL, reloading loses it and back does something surprising instead.
- Reload is the test. Press F5 on any screen; if you do not get the same screen back, something on it is not in the URL (The URL Is Application State).
- Share is the second test. Copy the address bar, open it in a private window, and see whether a colleague would land where you did.
- Back is the third test, and the one routers fail most often (History and Navigation).
What the browser stops doing for you
A real navigation is not one feature; it is about eight, bundled. The moment a handler calls preventDefault(), all eight become your responsibility simultaneously, and the ones nobody re-implements are always the same three: focus, announcement and scroll. They are invisible to a sighted mouse user on a fast laptop, which is a precise description of the person writing the router.
Read the right-hand column as a to-do list rather than as a warning. Each row is small on its own; the failure is that they are only ever done one at a time, in response to a bug report, and the accessibility ones do not generate bug reports.
| What a real navigation does | Who does it | After `preventDefault()` |
|---|---|---|
| Create a history entry and remember its scroll position | The browser | You call pushState; the scroll position is yours to save and restore (Scroll Restoration) |
| Show progress in the tab, with a stop button | The browser | Nothing appears. You own the indicator, and there is no way to cancel (Route Loading Boundaries) |
| Cancel every in-flight request for the old document | The browser | Old responses land in the new view unless you abort them (Cancelling a Request Nobody Is Waiting For) |
| Reset focus to the start of the new document | The browser | Focus stays on an anchor you have just unmounted (Focus Management) |
| Announce the new document to assistive technology | Browser and AT together | Silence. The user cannot tell the page changed (Live Regions and Announcement) |
| Scroll to the top, or to the fragment, or to the saved position | The browser | The old page's scroll offset persists into the new view |
| Destroy all script state, timers, listeners and observers | The browser | Everything survives. That is the feature, and the leak (Memory Leaks) |
| Set the document title and metadata for the new page | The server, via the response | The old title stays until you change it (The Head: Metadata That Changes Rendering) |
Intercepting narrowly, and announcing at all
The interception itself is mostly a list of navigations you must not steal. Each guard below corresponds to a real bug report: cmd-click stopped opening a tab, the invoice download rendered a blank page, the in-page anchor stopped scrolling, the mailto: link did nothing.
What follows the interception is the part that is usually missing entirely. After the new view has committed, three things must happen: the title is updated, focus is moved into the new content, and — if focusing did not already produce a useful announcement — the route announcer is updated. Order matters, and so does timing: doing any of it before the new content exists focuses an empty container and announces an empty string.
semantics The destination is an ordinary document region, not a widget: a main landmark containing exactly one h1 that names the page. The route announcer is a separate, visually hidden element with aria-live="polite" and aria-atomic="true", kept in the DOM permanently — a live region added at the same moment as its content is frequently missed.
| Enter (on a link) | Activates the anchor. Works only if the element is a real a with an href; a div with a click handler is unreachable this way (Keyboard Operability). |
| Browser Back / Forward | Traverses session history. Must produce the same focus and announcement behaviour as a forward navigation, which is where most routers stop. |
| Tab | Moves to the next focusable element after the newly focused container — that is, into the new page rather than back at the document start. |
| H / landmark keys (screen reader) | Jumps by heading or landmark. This is why the new view needs one h1 and a main, not a div soup (Div Soup: How It Happens and What It Costs). |
- — Move focus after the new content has committed to the DOM, never before.
- — Target a container or heading carrying
tabindex="-1"so it is programmatically focusable but not in the tab order. - — Suppress the focus ring on the container only if you have a visible alternative; removing it outright hides where the user now is.
- — Do not move focus on a same-page state update — only on a navigation. Focus that moves while someone is typing is worse than focus that never moves.
- — Restore focus deliberately on back: returning to a list should ideally return focus near the item the user left from, not to the top.
- — The new page name, once, politely — after
document.titlehas been updated. - — The loading state, if resolution takes long enough to be noticeable, via the same or a paired live region (Route Loading Boundaries).
- — The failure, if the route could not be resolved. Silence after a failed navigation is the worst outcome available.
usually broken by The pattern invites two opposite mistakes. The first is focusing and announcing on every render rather than on every navigation, which rips focus away mid-typing and produces a stream of chatter. The second is treating back and forward as not-really-navigations, so a user who arrives via the back button gets no focus move and no announcement at all — which is exactly how they arrive most of the time.
1document.addEventListener('click', (event) => {2 if (event.defaultPrevented) return // someone upstream already handled it3 if (event.button !== 0) return // middle-click opens a new tab4 if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return5 6 const anchor = (event.target as Element).closest('a')7 if (!anchor) return8 if (anchor.target && anchor.target !== '_self') return9 if (anchor.hasAttribute('download')) return10 if (anchor.getAttribute('rel')?.split(/\s+/).includes('external')) return11 12 const url = new URL(anchor.href, location.href)13 if (url.origin !== location.origin) return // not ours to route14 if (url.pathname === location.pathname && url.hash) return // let the fragment jump happen15 16 event.preventDefault()17 void router.navigate(url)18})19 20// ...and after the destination view has committed to the DOM:21function afterNavigation(route: { title: string }) {22 document.title = route.title // 1. title first — it is what gets announced23 const main = document.getElementById('main') // 2. a container with tabindex="-1"24 main?.focus() // focus moves the user into the new content25 announcer.textContent = route.title // 3. aria-live="polite", visually hidden26}The routing is two lines. The rest is the boundary of what you are allowed to take over, plus the three lines that give a keyboard and screen-reader user any idea that something happened. Note that announcer and focus can double up: focusing a named region reads its name, so measure with a real screen reader before adding both.
How to build it
Most important first.
- Make the URL the source of truth for anything a user could reasonably want to link to: which record is open, which tab is selected, which filters are applied, which page of results. If it survives a reload, it belongs in the URL (The URL Is Application State).
- Render real
a hrefelements for every navigation. The href is what gives you cmd-click, middle-click, context menus, link previews, crawlability and keyboard activation for free — adivwith anonClickhas none of it (Semantics Are Behaviour). - Intercept narrowly. Handle only same-origin, unmodified, primary-button clicks on links without
targetordownload, and let the browser have everything else. - Hand back the jobs the browser was doing, in order of how loudly their absence is felt: focus, announcement, scroll, cancellation, progress, title.
- Abort the previous navigation's requests when a new one starts. An
AbortControllerper navigation is the smallest correct version of this (Cancelling a Request Nobody Is Waiting For). - Treat back and forward as first-class navigations, not as edge cases. If a state change is worth a URL, its reversal is worth working (History and Navigation).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- A client-side navigation moves no focus and announces nothing. To a screen-reader user, activating a link produced silence; to a keyboard user, focus is on a node that no longer exists, which resets the tab sequence to the document start.
- The fix is two mechanisms, not one. Focus management puts the user at the start of the new content — a container or heading with
tabindex="-1", focused after render. Announcement tells them the page changed, via a visually hiddenaria-live="polite"region that receives the new page name (Live Regions and Announcement). - Update
document.titleon every navigation, and update it before you announce. The title is what a screen reader reads on a real navigation, what appears in the tab, and what is stored in the bookmark (The Head: Metadata That Changes Rendering). - Skip links must keep working. If a "skip to content" link points at
#mainand the router replaces#mainon navigation, verify the target still exists and is focusable after the swap (Keyboard Operability). - Never announce and move focus for the same event in a way that interrupts itself: focusing an element causes its accessible name to be read, so focusing the new
h1often makes a separate announcement redundant and doubled.
What can go wrong
- The router renders a view for a URL it never pushed, or pushes a URL it never renders. Reload produces a different page than the one on screen — the classic "works until you refresh" bug.
- Interception is too broad and steals modified clicks, external links,
mailto:anddownloadlinks. - Interception is too narrow and misses links rendered inside a shadow root or a portal, so some links reload the whole application while others do not.
- The mitigation fails too: a router that resets focus on every render rather than on every navigation will yank focus out of an input as the user types in it.
- A route that renders nothing for an unmatched URL. Any typo, any stale bookmark, any trailing slash mismatch produces a blank page with no error (Route Matching).
- The application shell is remounted on every navigation, so the sidebar scroll position resets and any component state in the shell is lost.
- The previous route's data request and the new route's render. Without cancellation, the loser writes into the winner's view (Cancelling a Request Nobody Is Waiting For).
- Two navigations in flight — a user clicks a link, then immediately clicks back. Whichever resolution lands last wins, which may not be the one the URL now describes.
- A lazily loaded route chunk and a subsequent navigation away from it. The chunk arrives for a route nobody is on any more (Lazy Loading).
- Focus management and an async render: focusing a container before the new content has mounted focuses an empty box, and the announcement reads nothing.
- The URL is user input. Anything read out of a path or query and rendered is untrusted, and pushing it back into markup is a textbook DOM-based injection sink (Cross-Site Scripting).
- A redirect target taken from the URL —
?next=,?returnTo=— must be validated against an allowlist of paths on your own origin. Accepting an absolute URL sends users to an attacker's site with your product's branding as the setup (Login Redirects and the Open-Redirect Trap). - A route is not an authorization boundary. Hiding a route from the router hides it from the UI and from nobody else; the server must authorize every request the route makes (Authorization-Aware UI).
- Client routing does not change the origin. Same-origin policy, cookies, storage and CSP all still key off the origin the document was loaded from, not the path you pushed (The Same-Origin Policy).
- Because the document never reloads, a CSP violation, an expired session or a rotated deployment is not resolved by "just navigating". Long-lived tabs keep whatever they started with (Long-Lived Clients and Version Skew).
- "Client-side routing is faster." It removes the document round trip and re-executes nothing, which is a real saving; it does not remove the data request, and it adds the bundle to the first visit. Faster at what, on which visit, is the actual question.
- "The router owns navigation." The browser owns navigation. The router owns a narrow slice of it that it asked for by calling
preventDefault(). - "An SPA is the modern choice." It is one point on a curve that also includes static pages, server-rendered pages, islands and streaming. The right point depends on the product, not on the year (Choosing a Rendering Strategy).
- "State in the URL is a hack." State in the URL is the only state a user can send to a colleague. Everything else is private to one tab (The Seven Kinds of State).
- "We will add accessibility to the router later." A navigation that announces nothing is not a polish item; it is a screen-reader user being unable to tell whether their click worked (Semantics Before ARIA).
Measuring it, and what changes in the field
- The Network panel is the fastest tell: a real navigation clears the log and starts with a document request; a client navigation appends data requests to the existing log (Debugging the Network).
- The Performance panel shows the actual cost of the swap — script for the match and render, then style, layout, paint and composite for the new subtree (Layout, Paint and the Main Thread).
- Interaction latency from the field is what tells you whether the transition feels instant to real users on real devices, rather than on yours (Interaction Responsiveness).
- For accessibility, there is no panel: navigate the app with a screen reader running and with the mouse unplugged. Nothing else surfaces a missing announcement.
- On a slow network, the difference between navigation styles inverts in an interesting way: the client router avoids re-downloading the shell but still waits for data, and without a progress indicator that wait reads as a broken click (Loading, Error, Empty — The States You Did Not Render).
- On a slow device, the swap itself is script plus layout on the main thread, and a large route can produce a long task that blocks input during the transition (Long Tasks).
- On a first visit, a client router is strictly slower than the server rendering the same page, because the bundle must arrive and execute before any route can match at all (Client-Side Rendering).
- In a tab open for hours, accumulated per-route state, caches and listeners are the dominant cost, and the failure looks like "the app gets slower the more you use it".
- You are re-implementing a browser feature. Everything the browser did correctly is now yours to get right, forever, including the parts you will not think to test — modified clicks, back after a redirect, focus after an error.
- Nothing unloads. Client routing buys you preserved state and pays for it in retained memory and in bugs that only appear after the fifth navigation.
- The first paint is behind the bundle. Any routing that happens in JavaScript cannot happen before that JavaScript has been fetched, parsed and run (The Real Cost of JavaScript).
- The alternative is real, and is not a step backwards: a multi-page app gets all of this correct for free, and a server-driven navigation with cached shells is competitive for a large class of products (MPA vs SPA).
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALThe list of jobs a real navigation performs — history entry, scroll, focus, announcement, progress, request cancellation, document teardown — follows from the HTML navigation and session history specifications, so it is the same across Blink, Gecko and WebKit even though the visuals differ.
- FRAMEWORK-SPECIFICHow much is handed back varies sharply by router. Next.js ships a route announcer and manages focus; React Router provides an opt-in
ScrollRestorationcomponent but leaves focus to you; a hand-rolledpushStaterouter does none of it. Verify what your specific router does rather than assuming a category. - SPEC-EVOLVINGThe Navigation API replaces most of this interception with a single
navigateevent that a router can intercept, including for back and forward. It has shipped in Chromium and is not available everywhere, so treat it as a progressive enhancement over the history API rather than a replacement.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Testing & Reliability Engineering — a navigation is an end-to-end concern: modified clicks, back after redirect, reload on every route and focus after navigation are all behaviours a unit test on the route table cannot observe.