Articles

The History API and Client-Side Routing

The History API lets JavaScript change the URL and manage browser history without a page reload, which is what makes client-side routing possible.

Takina Takina · · 4 min read
An abstract illustration representing the web platform

The History API is the browser interface that lets JavaScript change the URL shown in the address bar and add or modify entries in the browser’s back/forward history, all without triggering a full page reload. It’s the piece of platform machinery that makes client-side routing possible: every JavaScript framework’s router — React Router, Vue Router, or a hand-rolled one — is built on top of these same few browser methods.

The core methods

Three methods do almost all of the work:

  • history.pushState(state, unused, url) — adds a new entry to the browser’s history stack and updates the address bar to url, without reloading the page. The state argument is arbitrary data associated with that history entry, retrievable later.
  • history.replaceState(state, unused, url) — the same idea, but it replaces the current history entry instead of adding a new one. No new back-button stop is created.
  • history.back(), history.forward(), history.go(n) — programmatically navigate the history stack, equivalent to the browser’s own back/forward buttons.

The middle unused parameter is a historical artifact — early specs intended it as a page title, no browser ever implemented that, and it’s simply ignored today. Most code passes an empty string.

// Navigate to /profile without a page reload
history.pushState({ page: "profile" }, "", "/profile");

Calling pushState only changes the URL and history stack. It does not fetch anything, render anything, or otherwise change what’s on screen — that part is entirely the router’s job, which is why a bare pushState call by itself does nothing visible until application code responds to it.

The popstate event

Changing the URL is only half of what a router needs. The other half is reacting when the user clicks the browser’s back or forward button, which is where the popstate event comes in — it fires on window whenever the active history entry changes due to user navigation (back/forward, or a programmatic history.go()), carrying the state object that was attached when that entry was created.

window.addEventListener("popstate", (event) => {
  renderPageFor(location.pathname, event.state);
});

Critically, popstate does not fire when your own code calls pushState or replaceState — only in response to actual history navigation. A router has to call its own render function immediately after every pushState call, and separately listen for popstate to handle back/forward navigation; forgetting the first half is a common bug that leaves the URL updated but the page content unchanged until the user happens to navigate away and back.

Why this replaced hash-based routing

Before the History API existed, client-side routers commonly used the URL fragment — everything after a # — to encode route state, since changing location.hash doesn’t trigger a page reload and was, at the time, the only way to get a shareable, bookmarkable URL that JavaScript could read and change freely. That approach still works and still shows up in specific cases (some static-hosting setups without server-side routing support lean on it deliberately), but it has real downsides: hash fragments were historically excluded from being sent to the server at all, which broke straightforward server-side rendering and made analytics harder, and a URL like example.com/#/profile reads as noticeably less clean than example.com/profile.

The History API’s pushState produces real, clean paths that look identical to URLs a traditional multi-page site would use, while still avoiding a full page reload. The trade-off is that it requires server cooperation: because /profile is now a real path the browser might request directly (a fresh page load, a bookmark, a shared link), the server has to be configured to serve the same single-page application shell for that path instead of returning a 404 — a detail that’s easy to overlook until someone refreshes the page on a client-routed URL and gets an error.

How this fits with modern frameworks

Frameworks that do server-side rendering or static generation still rely on the History API for the same purpose once a page has loaded and hydrated in the browser — the initial page comes from the server, but subsequent in-app navigation switches to client-side pushState calls to avoid full reloads on every click. This is also the layer beneath the browser’s View Transitions API, which can animate between two states of a page during exactly this kind of client-side navigation, and beneath the Speculation Rules API, which can prefetch or prerender pages a user is likely to navigate to next.

The takeaway

The History API is a small set of primitives — pushState, replaceState, and the popstate event — that together let JavaScript rewrite the address bar and manage back/forward navigation without a page reload. Every client-side router is a layer of application logic built on top of exactly these methods, and understanding them directly makes it much easier to debug the routing bugs — a stale back button, a refresh that 404s — that inevitably surface once a framework’s abstraction leaks.

Takina Takina · · 4 min read

Object.groupBy() in JavaScript, Explained

Object.groupBy() buckets array items by a key you compute, replacing the reduce() boilerplate developers have written for years. How it works.

#JavaScript #Web Development #Frontend
Takina Takina · · 4 min read

Svelte 5 Runes Explained

Svelte 5 runes like $state and $derived replace the old reactive-assignment magic with explicit function calls that work anywhere in a file.

#JavaScript #Frameworks #Web Development