History API
The History API lets JavaScript change the URL in the address bar without reloading the page. That single ability is what makes a Single-Page App feel like a multi-page site — back, forward and refresh all keep working, but transitions are instant. Every client-side router you have used (Next.js, React Router, Vue Router, SvelteKit) is built on top of pushState, replaceState and the popstate event.
The three building blocks
// 1. Push a new entry onto the history stack:
history.pushState({ page: "about" }, "", "/about");
// 2. Replace the current entry (no new back-stack entry):
history.replaceState({ page: "home" }, "", "/");
// 3. Listen for the user pressing back/forward:
addEventListener("popstate", (e) => {
console.log("URL is now", location.pathname, "state:", e.state);
});Both pushState and replaceState take (state, unused, url). The middle argument is a legacy title parameter — every browser ignores it; pass "".
Navigate without a full reload
A 12-line SPA router
function navigate(url) {
history.pushState(null, "", url);
render(url);
}
addEventListener("popstate", () => render(location.pathname));
document.body.addEventListener("click", (e) => {
const link = e.target.closest("a[data-spa]");
if (!link) return;
e.preventDefault();
navigate(link.getAttribute("href"));
});
function render(path) {
document.querySelector("#app").textContent = "You are on " + path;
}
render(location.pathname);Clicking <a data-spa href="/about"> updates the URL to /about and re-renders without reloading the page.
pushState vs replaceState
pushState— appends an entry. The back button will return here.replaceState— overwrites the current entry. Use it for canonical-URL normalisation (e.g. removing a trailing slash) and to update a URL after a redirect without polluting history.Both modify only the URL and the in-memory state — they never refetch HTML.
The state object
The state argument is structured-cloned and stored alongside the history entry. You can retrieve it later from history.state or event.state in popstate.
history.pushState(
{ listId: 42, scroll: window.scrollY },
"",
"/lists/42"
);
console.log(history.state);
// { listId: 42, scroll: 1240 }
addEventListener("popstate", (e) => {
if (e.state?.scroll != null) {
window.scrollTo(0, e.state.scroll);
}
});popstate: what actually fires it
The user clicks back or forward.
history.back(),history.forward(),history.go(±n)is called from code.It does not fire when you call
pushStateorreplaceState— you know you changed the URL, the browser has no surprise.It does not fire on full page reloads — those are normal navigations.
Hash routing vs History API
Before pushState, SPAs used location.hash (#/about) because the browser doesn't reload when only the hash changes. Hash routing still works, has no server-config requirements, and is fine for static demos. But the URL looks ugly and analytics tools may treat hash changes oddly.
Hash-based fallback
addEventListener("hashchange", () => {
const route = location.hash.slice(1) || "/";
render(route);
});
location.hash = "#/about"; // navigateWhat you must do server-side
Because the URL really changed, a hard refresh on /about sends a request to the server. Your server must reply with the same SPA HTML for every route — otherwise the user sees a 404 after refreshing. With Next.js App Router this is handled for you because each route has its own server-rendered entry.
The newer Navigation API
if ("navigation" in window) {
navigation.addEventListener("navigate", (e) => {
if (!e.canIntercept) return;
if (new URL(e.destination.url).origin !== location.origin) return;
e.intercept({
handler: async () => {
const url = new URL(e.destination.url);
await render(url.pathname);
},
});
});
}The Navigation API gives a single, modern entry point for all navigations — clicks, form submits, programmatic. Chromium-only as of writing; Safari and Firefox have not shipped it. Treat it as progressive enhancement.
Practical guidance
Use
pushStatefor navigations the user triggered.Use
replaceStatefor URL fixes — locale prefix, trailing slash, replacing a temporary?token=with a clean URL.Save scroll position in the state object so back-button restores it.
Intercept link clicks with
event.preventDefault()only when the link is same-origin and not modifier-clicked (Ctrl/Cmd/Shift/middle-click).Pair the client router with a server that serves the SPA on every route — otherwise hard-refreshes break.