Command Palette

Search for a command to run...

Hectal
PHASE 11Intermediate ~24 min· topic 5 of 8

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 for hashchange.
Fallback route
A server rule that returns the app's index.html for any app URL, so refreshing /about works.

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.

navigate.jswhole filejavascript
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.

popstate.jswhole filejavascript
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.

Follow one navigation throughdiagram
Rendering diagram…

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.

links.jswhole filejavascript
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.

vercel.jsonwhole filejson
{
  "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. 1

    Add a route

    In the hash router, add a route for /about that shows About us, and a link to it. Predict what clicking it logs.

  2. 2

    replaceState instead of pushState

    In the first example, change history.pushState to history.replaceState inside navigate. 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. 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

A history-backed router (state only, because the preview can't change its URL) ES2020+ New tab

`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

Press Run to see the page.

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 /about
A hash router with a route parameter New tab

The 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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

/ -> Home
/users -> Users: Asha, Ravi
/users/2 -> Profile of Ravi
/users/9 -> No such user
/nowhere -> 404
A path router for a real site (not live)javascript

Needs 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.

terminal
$ Page
── what you'll see ──
The demo logs render /, render /about, render /contact and then stops. Pressing Back changes the history position but the page still shows the Contact text.

Break #2

Push a URL on another origin

On any real page, run history.pushState(null, "", "https://example.com/") in the console.

terminal
$ Console
── what you'll see ──
Uncaught SecurityError: Failed to execute 'pushState' on 'History': A history state object with URL 'https://example.com/' cannot be created in a document with origin 'https://hectal.in' and URL 'https://hectal.in/...'.

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.

terminal
$ Browser
── what you'll see ──
404 Not Found (the host has no file called /about)

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 (saving scrollY in replaceState before navigating).

  • ▸

    Calling pushState too often (hundreds of times a second) is rate-limited by browsers and throws or is ignored. Use replaceState for state that changes continuously, such as a map position.

  • ▸

    The newer Navigation API (window.navigation, with a single navigate event that sees every navigation, including link clicks, and event.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. 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. 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() and history.go(n) move the pointer, exactly like the browser's buttons. history.length tells you how many entries there are, and history.state is the data attached to the current one.

  3. 3

    history.pushState(state, "", url) adds a new entry without loading anything: the address bar shows url, and state (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 a SecurityError. 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. 4

    pushState changes 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 calls history.back()), and the target entry belongs to the same document, the browser fires **popstate** on window, with event.state set 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. 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(), then pushState and render; (2) listen for popstate and 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 update document.title, move keyboard focus to the new heading (so screen reader users know the page changed) and handle scroll position.

  6. 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 no pushState. Its big advantage is that the server only ever sees /: no server setup. With path routing (/users/42 via pushState), a refresh or shared link makes the browser request /users/42 from the server, so the server must be configured to return the app's index.html for 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

01

What problems does client-side routing solve in a single-page app?

02

What do pushState, replaceState and popstate each do? Which of them fire events?

03

Compare hash routing and path (History API) routing.

04

What does a good router do besides swapping content?

Practice

01

Write matchRoute(path) that returns { name: "user", id: "42" } for /users/42, { name: "home" } for /, and null otherwise.

02

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, forward and go.

  • I can use pushState and replaceState and know they don't render or fire events.

  • I re-render on popstate and on start-up.

  • I can build a hash router with hashchange and route parameters.

  • I know why a path-routed SPA needs a server fallback.