Web Platform APIs · 4 / 10
lesson 4

The History API

pushState, replaceState, popstate — client-side routing in fifty lines.

~ 14 min read·lesson 4 of 10
0 / 10

When you click a link to another page, the browser leaves, fetches HTML, and rebuilds everything. But a single-page app wants to swap views without throwing the page away — keep the same JavaScript running, change what's shown, and update the URL bar so the back button still works. That last part is the History API. It lets you change the URL without leaving the page.

The whole API is three calls and one event. Once you've seen them, you can build a router by hand in an afternoon.

pushState changes the URL

history.pushState(state, title, url) updates the browser's address bar and adds an entry to the back stack — without making any network request.

navigate.js
/* User clicks "About". Update URL bar, no reload. */
history.pushState({ view: "about" }, "", "/about");

Three arguments:

  • state — any plain object (it gets cloned). The browser keeps it for you and hands it back when the user clicks back or forward.
  • title — historically meant to be the page title. Browsers ignore it. Pass "".
  • url — the new URL to show. Must be on the same origin as the current page.

After this line runs, the address bar shows /about and a new entry sits in the back stack. Nothing else happened. Your code is responsible for actually rendering the "About" view.

Tip

pushState is silent — it doesn't fire any event you can listen to. After calling it, you have to render the new view yourself. People expect a router to "respond" to URL changes; here, you're the one making them happen.

check your understanding
You call history.pushState({}, "", "/profile"). What happens to the page?

replaceState rewrites the slot

history.replaceState(state, title, url) is identical to pushState except it overwrites the current entry instead of adding a new one.

filter.js
/* User typed in a search box. Update URL but don't fill the back stack. */
const url = new URL(location.href);
url.searchParams.set("q", query);
history.replaceState({ q: query }, "", url);

Use replaceState when you want the URL to reflect state, but you don't want every keystroke to count as a back-stack entry. If you used pushState for each keystroke, the user would have to press back twenty times to leave a search page.

Rule of thumb: pushState for distinct navigations (clicked a link, opened a modal that should be backable). replaceState for fine-grained state you're shoving into the URL.

popstate fires on back

When the user clicks back or forward, the browser fires the popstate event on window. The event's state is the object you originally passed to pushState for that entry.

listen.js
window.addEventListener("popstate", (event) => {
/* event.state is whatever was pushed for this entry — or null. */
render(location.pathname, event.state);
});

Two things to know:

  • popstate fires for back/forward only. It does not fire when you call pushState. (That's why your code has to render after calling pushState — there's no event to react to.)
  • event.state is null for entries that weren't created with pushState (e.g. the original page load).
/home/list/item/3 back ←→ pushStatereplaceState
pushState appends; replaceState overwrites; popstate fires when the user uses back or forward.

A router in fifty lines

Pull the pieces together. The whole job of a router is: intercept clicks on internal links, push the new URL, and render the matching view. On back/forward, render based on the URL alone.

router.js
function navigate(path) {
history.pushState({}, "", path);
render(path);
}

/* Intercept clicks on same-origin links. */
document.addEventListener("click", (event) => {
const link = event.target.closest("a");
if (!link) return;
const url = new URL(link.href);
if (url.origin !== location.origin) return;   /* let external links go */
event.preventDefault();
navigate(url.pathname + url.search);
});

/* React to back/forward. */
window.addEventListener("popstate", () => render(location.pathname));

/* Render whatever's in the URL on first load. */
render(location.pathname);

render(path) is your responsibility — it switches what's displayed based on the path. The router itself is just three event handlers and one helper.

A few points worth pinning:

  • The click handler uses closest("a") so it works even if the user clicked a child of the link (an icon, a span).
  • url.origin !== location.origin lets external links behave normally — mailto:, off-site links, downloads. Don't intercept those.
  • On popstate, you read from location.pathname — the URL is already updated by the browser before the event fires.
  • Calling render once at startup handles the very first page load, where there's no event to listen for.
Watch out

Avoid storing huge objects in state. The browser persists every history entry, and large state inflates memory and breaks back/forward across reloads. Put identifiers in the URL; load data on render.

check your understanding
Your single-page-app code calls history.pushState when a user clicks a link, but the page never re-renders to show the new view. Why?

Try it yourself

check your understanding
You build a search page. Every keystroke updates ?q=... in the URL so users can copy the link. You want the back button to leave the page entirely on one press, not undo each keystroke. Which call should you use per keystroke?
check your understanding
What does event.state contain inside a popstate listener for an entry created by history.pushState({ id: 12 }, "", "/p/12")?
check your understanding
Your click handler calls navigate(url.pathname) for internal links. A user clicks a link with target="_blank". What should happen?
← prevnext lesson →
KeepLearningcertificate
for completing
Web Platform APIs
0 of 10 read