ESC

Type to search the knowledge base.

History API for SPA Routing

pushState, replaceState, and popstate — client-side routing without reloads, scroll restoration, and server fallback gotchas.

intermediate3 min read
  • javascript
  • history-api
  • spa
  • routing

SPAs change the URL without a full page load so the back button and shareable links still work. The browser piece is the History API: pushState, replaceState, and the popstate event. Framework routers wrap this; interviews expect you to know the raw API.

Core methods

// add a history entry (no reload)
history.pushState({ page: 2 }, '', '/products?page=2');

// replace current entry (no new back-stack item)
history.replaceState({ page: 1 }, '', '/products?page=1');

history.state; // { page: 1 } — your object (structured-cloneable)
history.length;

The second argument (title) is ignored by most browsers — pass ''.

window.addEventListener('popstate', (event) => {
  // back/forward navigation
  renderRoute(location.pathname, event.state);
});

Important: pushState / replaceState do not fire popstate. Only back/forward (and some history.go) do. After pushState, you render yourself.

Minimal router sketch

const routes = {
  '/': renderHome,
  '/about': renderAbout,
};

function navigate(path, { replace = false } = {}) {
  const url = path;
  if (replace) history.replaceState({ path }, '', url);
  else history.pushState({ path }, '', url);
  render(path);
}

function render(path) {
  const view = routes[path] ?? renderNotFound;
  document.querySelector('#app').replaceChildren(view());
}

window.addEventListener('popstate', (e) => {
  render(e.state?.path ?? location.pathname);
});

document.body.addEventListener('click', (e) => {
  const a = e.target.closest('a[data-link]');
  if (!a) return;
  e.preventDefault();
  navigate(a.getAttribute('href'));
});

// initial
render(location.pathname);

push vs replace

Use Method
User went somewhere (back should return) pushState
Fix URL after redirect, query sync, login replaceState
Wizard step that shouldn’t spam history often replaceState

Server must cooperate

A direct visit to https://app.com/about hits the server first. The server must serve your SPA shell (index.html) for client routes, or users get 404 on refresh.

# spa fallback idea (conceptually)
/* → /index.html

Hash routing (#/about) avoids server config but is uglier and worse for some SEO/analytics cases.

Scroll and focus

Browsers try to restore scroll on popstate. You can influence:

if ('scrollRestoration' in history) {
  history.scrollRestoration = 'manual';
}

Then set scroll in your render. Manage focus for a11y when the “page” changes.

Limits

  • State must be structured-cloneable (no functions, DOM nodes). Size caps exist — keep state small; rehydrate from URL when possible.
  • Cross-origin pushState is forbidden.
  • Don’t confuse with location.href = (full navigation).
// full reload navigation
location.assign('/logout');

Interview answer

“SPA routers call history.pushState to change the URL without reload and listen to popstate for back/forward. pushState doesn’t fire popstate, so the router renders immediately after pushing. replaceState updates the current entry. The server must fall back to the app shell for deep links. I keep history.state small and prefer the URL as source of truth.”

Query sync and transitions

function setQuery(params, { replace = true } = {}) {
  const url = new URL(location.href);
  for (const [k, v] of Object.entries(params)) {
    if (v == null) url.searchParams.delete(k);
    else url.searchParams.set(k, String(v));
  }
  const path = url.pathname + url.search + url.hash;
  if (replace) history.replaceState(history.state, '', path);
  else history.pushState(history.state, '', path);
}

Filters and pagination usually replaceState while typing and pushState when the user clicks “apply” or a page number — product call. Pair with pageshow / persisted for bfcache restores so you rehydrate UI when the user returns via back-forward cache.

Further reading

Related guides