Command Palette

Search for a command to run...

Hectal
PHASE 13Intermediate ~33 min· topic 1 of 6

Topic 13.1

What Client-Side Routing Is

In one line

Client-side routing lets a single-page app change the URL and the screen without asking the server for a new HTML page, using the browser's History API; the URL becomes part of your app's state, so pages can be bookmarked, shared and reloaded.

Think of it like this

A big book with a table of contents. In an old library, every chapter is a separate book: to read chapter 5 you walk back to the desk and ask for a new book (a full page load). In a single-page app you hold one big book, and the table of contents lets you flip straight to chapter 5. The page number written at the top of the page (the URL) still changes, so you can tell a friend "read page 42" and they land in the same place.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

URL
A web address, like https://shop.example/products/42?color=red#reviews. It has a path, an optional query string and an optional hash.
Multi-page app (MPA)
A site where every link loads a whole new HTML page from the server.
Single-page app (SPA)
A site that loads one HTML page once, then JavaScript draws every screen and changes the URL itself.
History API
Browser functions (history.pushState, history.replaceState, history.back) and the popstate event for changing the URL and history list without reloading.
popstate
The event the browser fires on window when the user moves through history with Back or Forward.
Search params
The ?key=value&key2=value2 part of a URL. Read and built with the URLSearchParams class.
Deep link
A URL that opens a page inside the app directly, not the home page.
SPA fallback (rewrite)
A server rule that answers every unknown path with index.html, so reloading a deep link works.
Route
A rule that says which component to show for which URL pattern.
Memory router
A router that keeps its history in memory instead of the address bar. Used in tests, previews and non-browser environments.

Step by step

01A multi-page click

In a classic site, clicking <a href="/about"> makes the browser send a request to the server, wait, download about.html, throw away the current page (and all its JavaScript state) and draw the new one.

A multi-page clickdiagram
Rendering diagram…

02A single-page click

In an SPA the link's click handler calls event.preventDefault(), so the browser doesn't leave. The router calls history.pushState, which changes the address bar, and React renders the About component in place. No request for HTML is made. If About needs data, it fetches only that data (Topic 13.5).

A single-page clickdiagram
Rendering diagram…

03The History API by hand

This is what a router does in a real browser tab. pushState adds an entry, replaceState overwrites the current one, and popstate tells you the user moved with Back or Forward. Note that pushState itself does not fire popstate: the router must re-render after it calls pushState.

history-by-hand.jswhole filejs
// In a normal browser tab (not in the preview frame)
history.pushState({ from: "home" }, "", "/about");
console.log(location.pathname); // "/about" (nothing was loaded)
console.log(history.state);     // { from: "home" }

history.replaceState(null, "", "/about?tab=team"); // same entry, new URL

window.addEventListener("popstate", (event) => {
  // Back or Forward was pressed: draw the screen for the new URL
  console.log("now at", location.pathname, event.state);
});

history.back(); // later prints: now at / null

04Decide what lives in the URL

Put in the URL what identifies what the user is looking at: the page, the item id, the open tab, filters, sort order, the search text, the page number. Keep in React state what is temporary: whether a dropdown is open, the text being typed before the search runs, a hover. Topic 15.1 maps every kind of state to its home.

url-anatomy.txtwhole filetext
https://shop.example/products/42?color=red&sort=price#reviews
\_____________________/\__________/\___________________/\______/
        origin             path         search params      hash
                       (which page)   (how it is shown)  (where on it)

05Make deep links work on the server

A reload or a shared link sends the full path to the server. Add the fallback rule for your host so every non-file path returns index.html. Real files (/assets/index-a1b2.js, /favicon.ico) are still served as files, because hosts check for a real file first.

vercel.jsonwhole filejson
{
  "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
}

06Let a router do it

Writing the History API code by hand is a good way to understand it (the first live example below does it with a pretend history). In a real app you use a router library, which also handles nested pages, relative links, scroll, data loading and many edge cases. Topic 13.2 starts React Router.

Try it yourself

  1. 1

    Add a page and a Forward button

    In the tiny router, add "/pricing": "Pricing" to pages and a link to it. Then add a forward() method (the opposite of back) and a Forward button. Predict: after About → Contact → Back → Forward, what does the history line print?

  2. 2

    Break the history list

    In push, replace the line with this.entries = [...this.entries, path]; (no slice). Click About, Contact, Back, then Home. Predict what history.entries looks like now and why real browsers drop forward entries when you navigate somewhere new.

  3. 3

    Two values for one key

    In the filters example, change next.set(key, value) to next.append(key, value) and click Blue twice. Look at the address bar: ?color=red&color=blue. Which one does params.get("color") return? Try params.getAll("color") to see both.

Code & diagrams

A tiny router from scratch React 18+ New tab

About → Contact → Back. The history list keeps `/contact` as a "forward" entry, exactly like a browser. `useSyncExternalStore` (Topic 10.3) is how a router subscribes to a URL that lives outside React.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

render /
render /about
render /contact
render /about
history: / /about /contact at 1
URL as state: filters in the search params New tab

The list is **derived** from the URL: there is no separate `color` state to keep in sync. Search param values are always **strings**, so the size is compared as `"9"`. Copy any of these URLs and the same list appears: that's a deep link.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

/shoes?color=red -> Runner, Climber
/shoes -> Runner, Walker, Climber
/shoes?size=9 -> Walker, Climber
/shoes?size=9&color=blue -> Walker
Why the live examples use memory routers New tab

Browsers only let a page `pushState` URLs on its **own** origin. A frame built from a string has no real origin, so the call throws a `SecurityError`. In your own app, served from `http://localhost:5173`, it works.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

my address: about:srcdoc
pushState failed: SecurityError
SPA fallback on common hostsbash
# Netlify: public/_redirects (copied into the build)
/*    /index.html   200

# nginx: inside the server block
location / {
  try_files $uri $uri/ /index.html;
}

# What a missing fallback looks like after deploying (reload on a deep link):
$ curl -I https://shop.example/products/42
HTTP/2 404

# After adding the fallback:
$ curl -I https://shop.example/products/42
HTTP/2 200
content-type: text/html; charset=utf-8

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Reload a deep link without a fallback

Build the app (npm run build) and serve the dist folder with a plain static server that has no SPA rule. Open /, click to /products/42 (works), then press Reload.

terminal
$ Browser after reload
── what you'll see ──
404: NOT_FOUND
(the host found no file called /products/42)

Break #2

Forget preventDefault in a hand-made Link

Remove e.preventDefault(); from the tiny router's Link.

terminal
$ Real browser tab
── what you'll see ──
The address bar changes, then the whole page reloads from the server: a white flash, and every piece of React state starts again.

Myth vs fact

Myth

An SPA has only one URL.

Fact

An SPA has only one HTML file, but it can have thousands of URLs. The History API changes the address without loading a document, and the router draws the right screen for each one.

Myth

Client-side routing makes the server unnecessary.

Fact

The server still has to answer every URL with index.html (the fallback), serve the JavaScript, and usually provide the data API. Server-rendered setups do even more.

Myth

The hash (#) is the way to do SPA routing.

Fact

Hash URLs (/#/about) were the old way, used before pushState existed, because changing the hash never reloads. They still work without a server fallback (React Router has HashRouter), but normal paths are cleaner, better for SEO and are the default today.

Myth

Anything in useState is as good as being in the URL.

Fact

State in memory is lost on reload and can't be shared or bookmarked. Filters, tabs, ids and page numbers usually belong in the URL.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    pushState does not fire popstate; only real history traversal (Back, Forward, history.go) does. Routers therefore notify their own listeners after every push, and listen to popstate only for traversal. The newer Navigation API (window.navigation, with a navigate event that intercepts every navigation) fixes many of these quirks; it arrived first in Chromium-based browsers, so check support before relying on it. React Router still uses the History API.

  • ▸

    Browsers restore scroll positions on Back for real page loads, but in an SPA the new content is drawn by JavaScript after the browser has already tried. Routers set history.scrollRestoration = "manual" and save positions themselves (Topic 13.6).

  • ▸

    The state object passed to pushState is stored with the history entry using the structured clone algorithm (so functions and DOM nodes can't go in it), survives reloads of that entry, and browsers limit its size. Keep it tiny: a from path, not your data.

  • ▸

    SPA fallback and SEO: a crawler that doesn't run JavaScript sees the same empty index.html for every URL. If search ranking matters, render on the server or pre-render (React Router framework mode, Next.js, Phase 20). Also make sure truly unknown paths still look like a not-found page (and ideally return status 404 from a server).

Remember this

  1. 1

    A multi-page app (MPA) is the classic web: every link asks the server (the computer that hosts the site) for a brand-new HTML page. The browser throws the old page away, downloads the new one, and draws it from scratch. That is simple and robust, but every click shows a short blank flash, and anything kept in JavaScript memory (a half-typed message, a playing video, the state of every component) is lost. A single-page app (SPA) loads one HTML page and one JavaScript bundle once. After that, JavaScript draws every "page" itself: clicking a link swaps components on the screen instead of loading a new document. React apps built with Vite are SPAs by default.

  2. 2

    The trick that makes SPA links feel like real pages is the History API, built into every browser. history.pushState(state, "", "/about") changes the address bar to /about and adds an entry to the browser's history list without loading anything. history.replaceState(...) changes the current entry instead of adding one. When the user presses Back or Forward, the browser moves along that list and fires a **popstate** event on window; the app listens for it, reads location.pathname, and draws the matching screen. history.state holds the small object you passed in (for example where the user came from). Client-side routing is exactly this loop: intercept link clicks with preventDefault(), call pushState, render the right component, and re-render on popstate.

  3. 3

    URLs are state. The URL is a piece of state that lives outside React, in the address bar. The path (/products/42) says which page; the query string or search params (?color=red&sort=price) say how it is filtered or sorted; the hash (#reviews) usually points at a spot on the page. Anything a user might want to bookmark, share or get back to with the Back button belongs in the URL, not only in useState. A good test: if you copy the address, send it to a friend, and they see something different, some state that should be in the URL isn't.

  4. 4

    A deep link is a URL that points to a page inside the app rather than its front door, such as https://shop.example/products/42?size=9. A user can open it from an email, a search result or a bookmark. For deep links to work, the app must be able to start on any URL: read the path and search params on the first render and show the right page, with no click history at all. This is why routers read the URL first and draw second, and why you should never assume the user arrived from your home page.

  5. 5

    SPA hosting needs a fallback. In an SPA there is only one real file, index.html. If a user reloads /products/42, the browser asks the server for /products/42, and a plain static server looks for a file with that name, finds none, and answers 404 Not Found. The fix is a fallback (also called a rewrite): tell the host "for any path that isn't a real file, send index.html", then the router in the browser reads /products/42 and shows the product. Vite's dev server already does this, so the bug often appears only after you deploy. Every host has a setting for it: a rewrites rule in vercel.json, a _redirects line in Netlify, try_files $uri /index.html; in nginx. Frameworks that render on the server (Next.js, React Router framework mode) handle every URL on the server, so they don't need it.

  6. 6

    A router library does all of this for you: it listens to the URL, matches it against a list of routes ("/about shows <About />"), renders the match, gives you a <Link> component that calls pushState instead of reloading, and handles Back and Forward. React has no router built in. The most used one is React Router, which you'll meet in Topic 13.2. Next.js has its own file-based router (Phase 20). In the live examples on this page, the code runs in a sandboxed frame whose address is about:srcdoc, so it can't change its own URL; the examples use a memory router that keeps the history in a JavaScript array and show the URL in a small address bar instead.

Explain it without notes

01

What is the difference between a multi-page app and a single-page app, from the user's and the browser's point of view?

02

Explain how pushState, replaceState and popstate work together in client-side routing.

03

Why do SPAs need a server fallback, and why does the bug usually appear only after deploying?

04

What does "URLs are state" mean, and how do you decide what goes in the URL?

Practice

01

Add a replace(path) method to the tiny router and use it for a "Go to login" button so that Back doesn't return to the page you replaced.

02

In the filters example, add a sort param with two buttons ("Name" and "Size") and sort the list from the URL.

03

Write the nginx and Vercel rules you would add to deploy an SPA whose routes include /dashboard/settings.

Trade-offs

  • ↔

    SPA navigation is fast and keeps state, but the first load is heavier and you must handle what the browser used to do for free: scroll position, focus, page titles and not-found pages (Topic 13.6).

  • ↔

    Putting state in the URL makes it shareable and survives reloads, but URLs get long, values are only strings, and every change can add a history entry. Use replace for small tweaks and keep private data out of URLs (they end up in logs and browser history).

Done when you can

  • I can explain how a single-page app changes pages without loading a new document.

  • I can use pushState, replaceState and popstate and say what each does.

  • I can decide which state belongs in the URL and read search params with URLSearchParams.

  • I can explain deep links and configure an SPA fallback on a host.

  • I know why the live examples use a memory router.