JavaScriptHistory API

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

JS
// 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

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

JS
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);
  }
});
Note
The state object is capped at 2 MB in most browsers. Keep it small. Store only what you need to restore a view; full data should live elsewhere.
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 pushState or replaceStateyou 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

JS
addEventListener("hashchange", () => {
  const route = location.hash.slice(1) || "/";
  render(route);
});

location.hash = "#/about"; // navigate
What 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

JS
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 pushState for navigations the user triggered.

  • Use replaceState for 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.