The History API
pushState, replaceState, popstate — client-side routing in fifty lines.
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.
/* 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.
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.
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.
/* 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.
window.addEventListener("popstate", (event) => {
/* event.state is whatever was pushed for this entry — or null. */
render(location.pathname, event.state);
});Two things to know:
popstatefires for back/forward only. It does not fire when you callpushState. (That's why your code has to render after callingpushState— there's no event to react to.)event.stateisnullfor entries that weren't created withpushState(e.g. the original page load).
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.
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.originlets external links behave normally —mailto:, off-site links, downloads. Don't intercept those.- On
popstate, you read fromlocation.pathname— the URL is already updated by the browser before the event fires. - Calling
renderonce at startup handles the very first page load, where there's no event to listen for.
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.
history.pushState when a user clicks a link, but the page never re-renders to show the new view. Why?Try it yourself
?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?event.state contain inside a popstate listener for an entry created by history.pushState({ id: 12 }, "", "/p/12")?navigate(url.pathname) for internal links. A user clicks a link with target="_blank". What should happen?