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 thepopstateevent for changing the URL and history list without reloading. - popstate
- The event the browser fires on
windowwhen the user moves through history with Back or Forward. - Search params
- The
?key=value&key2=value2part of a URL. Read and built with theURLSearchParamsclass. - 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.
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).
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.
// 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 / null04Decide 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.
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.
{
"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
Add a page and a Forward button
In the tiny router, add
"/pricing": "Pricing"topagesand a link to it. Then add aforward()method (the opposite ofback) and a Forward button. Predict: after About → Contact → Back → Forward, what does the history line print? - 2
Break the history list
In
push, replace the line withthis.entries = [...this.entries, path];(noslice). Click About, Contact, Back, then Home. Predict whathistory.entrieslooks like now and why real browsers drop forward entries when you navigate somewhere new. - 3
Two values for one key
In the filters example, change
next.set(key, value)tonext.append(key, value)and click Blue twice. Look at the address bar:?color=red&color=blue. Which one doesparams.get("color")return? Tryparams.getAll("color")to see both.
Code & diagrams
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
Console
Nothing logged yet.Expected console output
render /
render /about
render /contact
render /about
history: / /about /contact at 1The 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
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 -> WalkerBrowsers 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
Console
Nothing logged yet.Expected console output
my address: about:srcdoc
pushState failed: SecurityError# 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-8Break 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.
Break #2
Forget preventDefault in a hand-made Link
Remove e.preventDefault(); from the tiny router's Link.
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.
- ▸
pushStatedoes not firepopstate; only real history traversal (Back, Forward,history.go) does. Routers therefore notify their own listeners after everypush, and listen topopstateonly for traversal. The newer Navigation API (window.navigation, with anavigateevent 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
stateobject passed topushStateis 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: afrompath, not your data. - ▸
SPA fallback and SEO: a crawler that doesn't run JavaScript sees the same empty
index.htmlfor 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
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
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/aboutand 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 onwindow; the app listens for it, readslocation.pathname, and draws the matching screen.history.stateholds the small object you passed in (for example where the user came from). Client-side routing is exactly this loop: intercept link clicks withpreventDefault(), callpushState, render the right component, and re-render onpopstate. - 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 inuseState. 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
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
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, sendindex.html", then the router in the browser reads/products/42and 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: arewritesrule invercel.json, a_redirectsline 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
A router library does all of this for you: it listens to the URL, matches it against a list of routes ("
/aboutshows<About />"), renders the match, gives you a<Link>component that callspushStateinstead 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 isabout: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
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?
Explain how pushState, replaceState and popstate work together in client-side routing.
Why do SPAs need a server fallback, and why does the bug usually appear only after deploying?
What does "URLs are state" mean, and how do you decide what goes in the URL?
Practice
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.
In the filters example, add a sort param with two buttons ("Name" and "Size") and sort the list from the URL.
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
replacefor 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,replaceStateandpopstateand 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.