Skip to content

Chapter 26: Routing Belongs to the Server

This is one of the more consequential architectural arguments in the book, and it’s worth being direct about it from the start.

A URL change is a server request. The browser already knows how to do this. It does it well. It does it with progressive enhancement, with HTTP caching, with browser-history integration, with the back button and forward button and tab restoration and link sharing and search-engine indexing all working correctly. The platform’s navigation model is one of its oldest and best-developed capabilities.

The SPA generation pulled navigation into the client. The cost of that move has been substantial, and the benefits are increasingly hard to defend now that the platform has caught up in the ergonomic areas where client routing previously had an advantage. The argument this chapter makes is that for most applications, in 2025, routing should go back to the server — with a clear definition of what navigation means (it changes the URL, it produces a new page) and what in-page state means (it doesn’t change the URL, or changes it only in shallow ways that the server doesn’t care about).

This is the position Kitsune takes. The chapter defends it carefully because it pushes against an inherited assumption every reader brings.

What the Browser Already Does for Navigation

Section titled “What the Browser Already Does for Navigation”

Start with what the platform provides, free, for any web application that uses real URLs.

Address bar. The user can see where they are. The URL is shareable. Pasting the URL into a new tab takes the user to the same place. Bookmarking works. Browser history works. The user’s sense of place in the application is anchored by something the user can see and trust.

Back and forward buttons. The browser’s history maintains an ordered stack of URLs. The back button takes the user to the previous URL. The forward button moves forward. Tab restoration after a crash returns the user to their last URL. The user’s mental model — go back, go forward, undo my last navigation — is implemented natively, without the application having to do anything.

HTTP caching. When the browser requests a URL, intermediate caches (the user’s browser cache, the user’s ISP, the origin’s CDN, any reverse proxy along the way) can serve the response without the origin being involved. The same URL returning the same content is the foundation of one of the most effective performance optimizations in computing. SPAs largely opt out of this — a single-page application typically has one HTML entry point that all routes share, so the cache benefits don’t apply.

Search engine indexing. Search engines crawl URLs. A URL that returns a real HTML document gets indexed. A URL that requires JavaScript to render is harder to index reliably — search engines have improved at this over the years, but server-rendered pages remain easier for crawlers to handle than client-rendered ones.

Link previews. Social media platforms, chat applications, email clients, and link aggregators read the OpenGraph metadata of a URL to produce a preview. The preview requires the URL to return a meaningful HTML document. URLs that depend on client-side rendering produce broken or empty previews.

Browser autofill. The browser remembers form data per URL. A user who fills in a form on /profile and navigates away can come back to the same URL and find autofilled values. SPAs that don’t change URLs as the user moves through different forms break this.

Password managers. Same shape — password managers attach credentials to URLs. A URL that doesn’t change as the user moves between login states confuses the password manager’s heuristics.

Accessibility. Screen readers announce page changes when the URL changes and a new document loads. SPAs that change content without changing the URL — or that change the URL without doing a real navigation — produce inconsistent screen-reader announcements. The platform’s navigation as a real event model integrates with assistive technology in ways client-side routing has to recreate manually.

Reload. The user can press Cmd-R or F5 and the browser re-requests the current URL. The page reloads to a consistent state. The application can rely on this — if everything is broken, the user can reload. SPAs that get into an inconsistent client-side state may not recover from a reload if the broken state is in localStorage or similar.

This is a long list. The cumulative weight is the chapter’s argument. The browser is good at navigation. Replacing the navigation model with a client-side router means reimplementing each of these capabilities in JavaScript, usually less reliably than the browser implements them natively.

The single-page application generation, described in Chapters 11 through 15, made a specific architectural choice. Each route became a JavaScript-rendered view, and the URL was managed by a client-side router using the History API.

The reasoning was sound for its moment. Full-page reloads in 2010 felt slow. The white flash between pages broke the sense of continuity. Server-rendered HTML required server-side templating, which meant duplicating logic between the server and the client. Routing in the client meant the application could pre-load data, transition smoothly, and feel like a desktop application.

Backbone’s router. AngularJS’s ngRoute. Ember’s robust router. React Router. Vue Router. The Next.js Pages Router. The Next.js App Router. Remix’s nested routes. SvelteKit’s filesystem routing. Each generation of frontend tooling has shipped a client-side router as standard equipment. The pattern is so universal that frontend application and single-page application with client-side routing have become roughly synonymous in most teams’ working vocabulary.

The pattern works. Millions of production applications run on it. The argument isn’t that client-side routing is broken. The argument is that the costs have always been higher than they appear, and the benefits have shrunk over time as the platform improved.

A short tour of what client-side routing requires applications to do that server-side routing doesn’t.

Intercept link clicks. A client router has to intercept clicks on links to its own routes and call event.preventDefault() to stop the default navigation. The interception has to handle edge cases — modifier keys (Cmd-click should still open in a new tab), middle-click, right-click, drag-as-link, programmatic navigation. Every router has bugs in this area, sometimes for years.

Maintain the URL with the History API. history.pushState and history.replaceState let the application update the URL without reloading. The application has to decide when to push (new entry, back button takes you back) versus replace (no new entry, back button skips this state). Mistakes here produce a broken back-button experience.

Handle popstate. When the user clicks back or forward, the browser fires a popstate event. The client router has to interpret the new URL and render the right view. The state object the browser provides may or may not have useful data; the application usually has to re-derive the route state from the URL.

Scroll restoration. When the user navigates back to a previous URL, the browser used to restore scroll position automatically. Client-side routing breaks this — the application has to manage scroll state itself, store it per route, restore it on back navigation. Most SPAs do this badly. Some don’t do it at all.

Title and meta tags. The browser used to set the page title from the <title> element when a new page loaded. Client routing keeps the same document, so the title has to be updated manually. The react-helmet ecosystem and equivalents in other frameworks exist mostly to solve this.

Loading states. When the user clicks a link, the server-rendered web shows a loading indicator in the browser chrome (the spinning favicon, the progress bar, the URL bar showing the destination). The user has feedback that something is happening. Client routing has to provide its own loading indicators per route, and the application’s UI has to handle the loading state for the data the new route needs.

Error states. When a server-rendered request fails, the user sees a clear error page (404, 500, whatever). Client routing has to catch errors, decide whether they’re route errors or data-loading errors, and render the right error UI. Error boundaries became a React feature largely because client-side rendering doesn’t have the platform’s error pages as a fallback.

Cancellation. When the user navigates away from a route while data is still loading, the application has to cancel the in-flight requests. AbortController makes this possible but adds complexity. Most applications do this incompletely; you can find dropped requests in any sufficiently large SPA’s network panel.

Cache invalidation for client-side data. The application has to decide when route data is stale. When the user comes back to a page they were on five minutes ago, does the data refresh? Does it use a cached version? How is the cache invalidated when data changes elsewhere? Libraries like TanStack Query exist largely to manage this — and the management is itself complex enough to be a major source of bugs.

Server / client state mismatch. Meta-frameworks that hydrate server-rendered HTML have to ensure the client’s first render matches what the server produced. Mismatches produce hydration errors, console warnings, and sometimes visible UI flickers. The whole hydration problem space exists because client-side rendering had to be retrofitted to coexist with server rendering.

The list is long. Each item is solvable. Each item has been solved by multiple libraries over the past fifteen years. The cumulative cost is real, and the maintenance burden compounds as applications age and frameworks evolve.

The strongest historical argument for client-side routing was the white flash. Server-rendered navigation produced a visible page-reload flicker between routes. SPAs avoided the flicker by keeping the document alive across navigations. Users perceived the SPA experience as smoother, and the smoothness was a real product win.

The View Transitions API, shipped in Chrome in 2023 and reaching cross-browser support through 2024–2025, eliminates this argument.

The same-document version (introduced first) lets an application animate between two states of the same page:

document.startViewTransition(() => {
// update the DOM
})

The browser captures the current visual state, applies the DOM changes, captures the new visual state, and animates between them. Elements can be tagged with view-transition-name in CSS to be animated as continuous units. The result is the kind of smooth state transition that SPAs have been building manually for years, provided natively by the browser.

The cross-document version, which landed later, applies the same mechanism to actual page navigations:

@view-transition {
navigation: auto;
}

With this single CSS declaration, the browser will animate transitions between pages — real server-rendered pages, with full document reloads — using the same view-transition machinery. Elements tagged with view-transition-name are matched across pages and animated as continuous units. The white flash disappears. The transition is smooth. The navigation is still a real server request.

This is the ergonomic loop the routing-belongs-to-the-server argument needed. The historical reason for client-side routing was server-rendered navigation feels janky. The View Transitions API makes server-rendered navigation feel as smooth as SPA navigation, while preserving the platform’s full navigation model — addressability, caching, history, accessibility, autofill, password managers, search-engine indexing, all of it.

Jake Archibald and Bramus Van Damme on the Chrome team led the View Transitions work. The API’s design closed what was, for a long time, the strongest single argument against the platform’s navigation model. With View Transitions, the argument flips. Why are we still doing client-side routing if the browser can do the visual continuity natively?

The chapter has been pushing one direction, but the line is worth drawing carefully.

Navigation is a URL change that represents a new place in the application. The user has moved from one logical location to another. The address bar reflects the new location. The back button returns to the previous one. New navigation deserves to be a server request — the server is the right place to decide what the new page contains, what data it needs, what response code is appropriate, what cache headers to set.

In-page state is a change that doesn’t move the user. The user is still in the same logical place; some aspect of the page’s state has changed. A menu opening or closing. A form field receiving input. A modal opening. A toggle switching. A tab within a tabbed interface changing. None of these is a navigation. None of them should be a URL change in the usual sense.

The interesting middle case is URL-syncable in-page state. Some kinds of in-page state should be reflected in the URL even though they aren’t navigations. The current tab of a tabbed interface, maybe. The search query and filters on a list. The sort order of a table. These benefit from URL representation because the user might want to share the state with someone else, bookmark it, or recover it after a reload. They don’t benefit from being treated as full navigations because the server doesn’t necessarily need to return a different page for each variation.

The platform’s answer for URL-syncable in-page state is URLSearchParams plus history.replaceState:

const url = new URL(location.href)
url.searchParams.set('sort', 'price')
history.replaceState(null, '', url)

The URL updates. The browser’s history doesn’t grow (the back button still goes to the previous navigation, not back through every sort change). The state is now shareable and reload-recoverable.

This is the line Kitsune draws. Navigation goes to the server. In-page state stays on the client. URL-syncable in-page state uses URLSearchParams and history.replaceState, without involving a router framework. The architecture leans on the platform for the navigation half and handles the in-page state through application state — which, after Chapter 25, lives in storage and is observed reactively.

A small style guide, because the question comes up.

URLs should encode what the user can see and what the server needs to know. Path segments name resources. Query parameters name filters, sorts, search queries, pagination, and similar request-shaping options. Fragment identifiers (#section) name in-document positions and may control client-side scrolling without a server request.

URLs should not encode transient UI state. A modal being open is not a URL state. A tooltip being visible is not a URL state. The position of a draggable element is not a URL state. The current value of a form field that hasn’t been submitted is not a URL state. These are transient — they exist for the duration of the user’s session with this page and don’t represent a place the user might want to share or return to.

URLs should not encode user-specific information that the server didn’t ask for. The user’s local theme preference is local state (storage). The user’s draft of an unsaved document is local state (storage). Anything that the server doesn’t need to be able to reconstruct shouldn’t be in the URL.

These rules are enough to produce clean URLs in most applications. The exceptions exist (a really complex single-page tool might have rich URL state that’s hard to fit), and they’re worth treating as exceptions rather than the rule.

The chapter has been arguing one direction. It should also name the cases where SPA routing legitimately fits, because the position isn’t every application should ditch its router tomorrow.

Real-time collaborative applications. Figma, Linear, Notion, multiplayer editing tools. The application’s state changes continuously, often in response to network events. Treating every state change as a server navigation isn’t viable. The application is more like a desktop application than like a website, and SPA architecture is the right fit. The URL still matters for sharing and deep-linking, but the in-page interactions don’t.

Long-running creative tools. Design editors, video editors, code editors, anything where the user spends an hour inside a single page making continuous changes. The cost of any kind of full reload (lost work, broken focus, broken undo stack) is too high. The architecture has to keep state in memory and persist it carefully.

Highly interactive dashboards. Analytics tools, monitoring dashboards, trading interfaces. The data changes frequently; the UI needs to update without flicker; the user is comparing values across many panels simultaneously. SPA architecture serves this well, and the alternative (server-rendered with rich client-side updates) is harder to get right.

Embedded applications. An application that lives inside another application’s chrome (an iframe, a desktop-app webview, a Chrome extension popup) often can’t rely on the host’s navigation model. SPA routing inside the embedded view may be the only option.

Some kinds of mobile-first applications. If the application is primarily delivered as a PWA installed on a phone, the navigation model is closer to a native app’s than to a website’s. SPA routing fits.

The pattern across these cases: the application is more like interactive software than like a navigable site. The URL is less central to the user’s experience. The continuous state matters more than the addressable places. For these applications, SPA routing earns its keep.

For everything else — content sites, e-commerce, documentation, admin dashboards, social applications, internal tools, marketing pages, blogs, news sites, government services, banking applications, the vast majority of what gets built on the web — the routing-belongs-to-the-server position is the better default. The exceptions are real, and they’re exceptions.

Kitsune’s design takes the position directly.

Navigation is server-side. Links are real <a href="..."> elements that perform real navigation. The server decides what the new page contains. View Transitions handle the visual continuity. The browser’s full navigation model — history, addressability, caching, accessibility — is preserved.

In-page state lives in storage (Chapter 25). Client-side state is reactive against the browser’s storage tier. State that should survive reloads is in localStorage or IndexedDB. State that should only live in the current tab is in sessionStorage. State that should be cross-tab synchronized propagates through storage events and BroadcastChannel.

URL-syncable in-page state uses URLSearchParams. Filters, sorts, search queries — anything where the user might want to share the URL — gets reflected in the search params via history.replaceState. No router framework is required.

Page context becomes boundary context. The route — represented in Kitsune as a <kit-boundary> with the route’s pathname, params, and metadata — is the outermost context boundary for events fired anywhere inside it. Modules listening to the runtime see events enriched with route context, the same way they see events enriched with surface, feature, and entity context.

No client-side router. No history-API plumbing. No scroll-restoration logic. No route-state caching layer. No react-router-dom, vue-router, @tanstack/react-router. The architecture leans on the platform’s navigation model and provides only the small amount of glue needed to make the route’s context available to the runtime.

The trade is real. Some applications won’t fit this model. The applications named in the previous section need SPA routing, and Kitsune doesn’t try to be the answer for them. For applications that do fit — and that’s a larger fraction of what teams build than the SPA-default assumes — the architecture is significantly simpler than the alternative.

The next chapter takes the same architectural-respect approach to CSS. CSS, like routing, has matured substantially in the past several years, and the platform now provides capabilities that earlier generations of frontend tooling worked around with JavaScript. The chapter argues that CSS is a runtime — an adaptation system that runs continuously, with custom properties, container queries, cascade layers, and the View Transitions API all functioning as platform-level programming constructs.

Exercise: Build a Routed Site Without a Router

Section titled “Exercise: Build a Routed Site Without a Router”

Build a small site with three pages: a home page, an about page, and a contact page. The site has shared navigation across the pages.

Build it with real server-side rendering. The simplest version is three static HTML files served from the same directory. If you prefer a server framework, use the smallest one you have access to — Express, Fastify, Astro, or just python -m http.server.

Add View Transitions:

@view-transition {
navigation: auto;
}

Add a view-transition-name to the elements that should animate as continuous units across pages (the header, maybe a hero image, maybe the navigation).

Open the site. Click between pages. Watch the transitions. Notice that the URL changes, the back button works, refresh recovers state, link sharing works, and there’s no JavaScript framework in the picture.

Now add some in-page state. A dark mode toggle whose state persists across pages. Implement it with the Chapter 25 pattern — localStorage, a synthetic event, a storage event listener. No router involved.

Now add some URL-syncable in-page state. On the contact page, add a small form with a topic dropdown (general, support, feedback). When the user changes the topic, update the URL with history.replaceState. When the page loads, read the topic from URLSearchParams and pre-select the value. No router involved.

Reflect on:

  1. How many libraries did this take? (Hopefully zero.)
  2. How much code did the application have to write for navigation? (Hopefully zero — the <a> tags did it.)
  3. How smooth did the page transitions feel? (Quite smooth, after the View Transitions API ran.)
  4. What’s the bundle size? (Tiny, if you wrote no JavaScript at all; small even with the dark mode + URL sync code.)
  5. What did the server’s response include for each page? (Real HTML, with content, no JavaScript shell.)
  6. How does this compare to the equivalent built with a meta-framework’s router?

The point is to feel that the platform’s navigation model is actually quite good. Most of the routing-related libraries the frontend ecosystem produces are reinventing what the browser already does. The View Transitions API is the missing piece that closes the ergonomic gap, and now that it ships in major browsers, the case for the platform’s navigation model is much stronger than it was even three years ago.