Topic 11.5
The History API and Client-Side Routing
In one line
A single-page app changes the view without loading a new page: history.pushState changes the address and adds a Back-button entry, the popstate event tells you when the user goes Back or Forward, and a router maps each URL to what should be shown; hash routing is the simpler, older variant.
Think of it like this
A photo album with a ribbon bookmark. Turning to a new page in the album is fast: you don't go back to the shop for a new album. But you still want to remember where you were, so each time you turn, you move a bookmark and write the page number on a little list. Pressing Back reads the list backwards. Client-side routing is exactly that: the page stays, the view changes, and the History API keeps the list of bookmarks so Back and Forward still work.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Single-page app (SPA)
- A site that loads one HTML page and then changes the view with JavaScript instead of loading new pages.
- Session history
- The list of pages and states a tab has visited, which Back and Forward move through.
- History entry
- One item in that list: a URL plus optional saved state.
- pushState
- Adds a new history entry and changes the address bar, without loading a page.
- replaceState
- Changes the current history entry instead of adding a new one.
- popstate
- The event fired when the user goes Back or Forward to an entry of the same page.
- Router
- Code that decides which view to show for the current URL.
- Hash routing
- Keeping the route after
#in the URL, like#/about, and listening forhashchange. - Fallback route
- A server rule that returns the app's
index.htmlfor any app URL, so refreshing/aboutworks.
Step by step
01The problem with swapping content
Swap a section's content on click and it looks like navigation, but the URL stays the same. Refresh: you're back on the first view. Back: you leave the site.
02Push an entry when you navigate
After rendering a new view, push an entry with its URL. The address bar updates and Back has somewhere to go.
function navigate(path) {
history.pushState({ path }, "", path);
render(path);
}03Render on popstate
pushState never fires popstate. Back and Forward do. Read the URL (or event.state) and render again, without pushing.
window.addEventListener("popstate", (event) => {
render(location.pathname); // or use event.state
});04Follow one navigation through
The two ways into render meet in the same place. That's the whole idea of a router.
05Intercept links with delegation
One listener on the document catches clicks on every internal link, including links added later. Leave modified clicks (Ctrl/Cmd for a new tab), external links and downloads to the browser.
document.addEventListener("click", (e) => {
const a = e.target.closest("a[href]");
if (!a || a.origin !== location.origin || a.target || a.hasAttribute("download")) return;
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
e.preventDefault();
navigate(a.pathname + a.search + a.hash);
});06Make refresh work on the server
Every app path must return the same index.html. Static hosts call this a rewrite or SPA fallback. Keep real files (/assets/app.js) and API routes out of the rewrite.
{
"rewrites": [{ "source": "/((?!api/|assets/).*)", "destination": "/index.html" }]
}07Or use the hash
With hash routing, the server always serves /, and the router reads location.hash on load and on hashchange. It's a fine choice for small tools, demos and static hosting you can't configure.
Try it yourself
- 1
Add a route
In the hash router, add a route for
/aboutthat showsAbout us, and a link to it. Predict what clicking it logs. - 2
replaceState instead of pushState
In the first example, change
history.pushStatetohistory.replaceStateinsidenavigate. Predict: after clicking About then Contact, what does Back do now? (Careful: with no entries of its own, Back may leave the preview's history entirely, so try it with the button, not the demo.) - 3
Look at a real SPA
On a site built with a framework (Hectal itself uses Next.js), open the console and run
addEventListener("popstate", (e) => console.log("popstate", location.pathname)). Click a few internal links (nothing logs: pushState is silent), then press Back (it logs).
Code & diagrams
`pushState` itself fired nothing: the renders after the clicks come from `navigate`. Back and Forward fired `popstate` with the saved state; the first entry has no state, so it's `null`. Use the links and the Back button yourself afterwards.
Preview
Console
Nothing logged yet.Expected console output
render /
render /about
render /contact
popstate, state = { path: '/about' }
render /about
popstate, state = null
render /
popstate, state = { path: '/about' }
render /aboutThe route `/users/:id` is a regular expression with a capture group; the captured text is passed to the view. Each hash change adds a history entry, so Back steps through them.
Preview
Console
Nothing logged yet.Expected console output
/ -> Home
/users -> Users: Asha, Ravi
/users/2 -> Profile of Ravi
/users/9 -> No such user
/nowhere -> 404Needs a server that returns index.html for /about. On a real origin, pushState can change the path.
const routes = {
"/": () => "<h1>Home</h1>",
"/about": () => "<h1>About us</h1>",
};
function render() {
const view = routes[location.pathname] ?? (() => "<h1>Not found</h1>");
const main = document.querySelector("main");
main.innerHTML = view(); // trusted templates only: never put user input in innerHTML
const h1 = main.querySelector("h1");
document.title = h1.textContent + " | My site";
h1.tabIndex = -1;
h1.focus(); // tell keyboard and screen reader users the page changed
}
export function navigate(url) {
if (url === location.pathname) return;
history.pushState(null, "", url);
render();
window.scrollTo(0, 0);
}
document.addEventListener("click", (e) => {
const a = e.target.closest("a[href]");
if (!a || a.origin !== location.origin || a.target || e.ctrlKey || e.metaKey) return;
e.preventDefault();
navigate(a.pathname);
});
window.addEventListener("popstate", render);
render();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
Forget the popstate listener
In the first example, delete the window.addEventListener("popstate", ...) block.
Break #2
Push a URL on another origin
On any real page, run history.pushState(null, "", "https://example.com/") in the console.
Break #3
Refresh a deep link without a server fallback
Deploy a path-routed SPA to static hosting without a rewrite rule, open /about via the app, then press refresh.
Myth vs fact
Myth
pushState fires popstate.
Fact
It fires nothing. Only Back/Forward (or history.back/forward/go) and hash changes fire popstate. Render yourself after pushing.
Myth
popstate fires when the page first loads.
Fact
Old Chrome and Safari did; current browsers don't. Render once on start-up yourself.
Myth
Hash routing is bad and outdated.
Fact
It's simpler and needs no server setup. Path routing gives cleaner URLs and better SEO, but hash routing is still a reasonable choice for internal tools and static demos.
Myth
The second argument of pushState sets the page title.
Fact
Browsers ignore it. Set document.title yourself.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
History state is stored with the structured clone algorithm, so it can hold dates, maps and nested objects but not functions or DOM nodes. Browsers cap its size (Firefox allows about 16 MiB). Keep it small: store IDs and scroll offsets, not whole API responses.
- ▸
history.scrollRestoration = "manual"stops the browser from restoring scroll on Back/Forward, so your router can restore it after the new view has rendered (savingscrollYinreplaceStatebefore navigating). - ▸
Calling
pushStatetoo often (hundreds of times a second) is rate-limited by browsers and throws or is ignored. UsereplaceStatefor state that changes continuously, such as a map position. - ▸
The newer Navigation API (
window.navigation, with a singlenavigateevent that sees every navigation, including link clicks, andevent.intercept()) was designed to replace this pattern. Chromium has supported it for years and other engines have been adding it; check support before relying on it.
Remember this
- 1
In a normal multi-page site, clicking a link makes the browser request a whole new HTML page. A single-page app (SPA) loads one page once and then swaps parts of it with JavaScript, fetching only data (Topic 11.2). That's faster and smoother, but it breaks things users expect: the address bar doesn't change, links can't be shared, refresh loses your place, and Back leaves the app. Client-side routing fixes all four by keeping the URL in step with what's on screen.
- 2
The browser keeps a session history: a list of entries for the tab, one per page or state you visited, with a pointer to the current one.
history.back(),history.forward()andhistory.go(n)move the pointer, exactly like the browser's buttons.history.lengthtells you how many entries there are, andhistory.stateis the data attached to the current one. - 3
history.pushState(state, "", url)adds a new entry without loading anything: the address bar showsurl, andstate(any data that can be copied with structured clone, such as{ page: "about" }) is saved with the entry. The middle argument is unused (it was meant to be a title); pass"". The URL must be on the same origin, or it throws aSecurityError.history.replaceState(state, "", url)does the same but replaces the current entry instead of adding one, which suits small changes like a filter you don't want in Back history. - 4
pushStatechanges the address but doesn't render anything and doesn't fire any event. You must update the page yourself. When the user presses Back or Forward (or your code callshistory.back()), and the target entry belongs to the same document, the browser fires **popstate** onwindow, withevent.stateset to that entry's state. Your router listens for it and renders the view for the now-current URL. So a router has two paths in: your navigation (pushState, then render) and the user's Back/Forward (popstate, then render). - 5
A router is the code that maps a URL to a view. The usual design: (1) intercept clicks on internal links with event delegation (Topic 9.3), call
preventDefault(), thenpushStateand render; (2) listen forpopstateand render; (3) render once on load from the current URL. Matching can be a lookup table ("/about"→aboutView) plus patterns with parameters (/users/:id). Good routers also updatedocument.title, move keyboard focus to the new heading (so screen reader users know the page changed) and handle scroll position. - 6
Hash routing puts the route after
#:https://site.com/#/users/42. Changing the hash never loads a page and fires **hashchange**, so it needs nopushState. Its big advantage is that the server only ever sees/: no server setup. With path routing (/users/42viapushState), a refresh or shared link makes the browser request/users/42from the server, so the server must be configured to return the app'sindex.htmlfor every app route (a fallback or rewrite), or users get a 404. Path URLs look cleaner and work better for search engines, which is why frameworks such as Next.js and React Router use them.
Explain it without notes
What problems does client-side routing solve in a single-page app?
What do pushState, replaceState and popstate each do? Which of them fire events?
Compare hash routing and path (History API) routing.
What does a good router do besides swapping content?
Practice
Write matchRoute(path) that returns { name: "user", id: "42" } for /users/42, { name: "home" } for /, and null otherwise.
Write a navigate(path) that skips pushing when path equals the current route and otherwise pushes and renders.
Trade-offs
- ↔
Client-side routing makes navigation instant after the first load, but the first load ships the router and views, and without server rendering the first paint and SEO suffer.
- ↔
Writing your own small router is a great way to learn and fine for a few routes; real apps usually need nested routes, data loading, code splitting and error states, which a framework router handles.
Done when you can
I can explain session history and move through it with
back,forwardandgo.I can use
pushStateandreplaceStateand know they don't render or fire events.I re-render on
popstateand on start-up.I can build a hash router with
hashchangeand route parameters.I know why a path-routed SPA needs a server fallback.