History API
The History API lets a single-page app change the URL without a page load:
history.pushState(state, '', url)adds an entry to the session history,history.replaceState(state, '', url)replaces the current one. The URL in the address bar changes, but no request is sent and no event fires: the app renders the new view itself.- When the user goes back or forward, the browser fires
popstatewith the entry'sstate, and the app renders the view forlocation.
A client-side router is these two parts plus intercepting link clicks. The newer
Navigation API puts all of it into one navigate event.
Details
pushState and replaceState
- The URL must have the same origin as the page, otherwise a
SecurityErroris thrown. A relative URL works:pushState(null, '', '?tab=2'). - The second argument (
title) is ignored by browsers; pass''. stateis stored as a structured clone (likepostMessage), so functions and DOM nodes throwDataCloneError. It survives a reload and comes back ashistory.state. Keep it small: an ID or a scroll position, not the page data.replaceStateis for updates that should not create a back step: filters typed into a search field, a corrected URL after a redirect.history.back(),forward()andgo(n)move through the history asynchronously, like the browser buttons, and firepopstatewhen the target entry belongs to the same document.
popstate and hashchange
popstatefires on traversal (back, forward,history.go()) between entries of the same document, and also on fragment navigation (#section). It does not fire forpushState/replaceStateor on page load.hashchangefires when only the fragment changes: a click on#idorlocation.hash = 'id'. It fires afterpopstate, and not afterpushState, even if the URL had a different hash.history.scrollRestoration = 'manual'turns off the browser's own scroll restoration on back / forward, so the router can restore the position after the content is rendered.
Hash vs History Routing
Hash routing (/#/users/1) | History routing (/users/1) | |
|---|---|---|
| Events | hashchange | pushState + popstate |
| What the server sees | Always /: the hash is not sent | The full path: it must return index.html for all |
| SEO and SSR | Poor | Normal URLs |
Anchors #id | Taken by the router | Work as usual |
Server configuration and React Router's HashRouter are covered in
React Router.
Navigation API
navigation.addEventListener('navigate', handler)receives every navigation the page starts: link clicks, form submissions, back / forward,location.href = …,history.pushState. Typing in the address bar and bookmarks are not included.event.intercept({ handler })turns it into a same-document navigation: the browser updates the URL, the asynchandlerrenders the view, and the page does not reload. No manual click handling and no separatepopstate.event.canInterceptisfalsefor cross-origin URLs and for back / forward to another document;event.hashChange,event.downloadRequestandevent.formDatadescribe the navigation.navigation.entries()lists the tab's same-origin history entries (historyhides them),navigation.currentEntry.getState()returns its state,navigation.navigate(url, { state })andnavigation.traverseTo(key)navigate and return{ committed, finished }, two promises.event.signalaborts when the user starts another navigation, so a stale fetch can be canceled.
Support: the History API is Baseline; the Navigation API has been Baseline since January 2026.
Example
A minimal router: link delegation, pushState and popstate:
const routes = {
'/': () => '<h1>Home</h1>',
'/about': () => '<h1>About</h1>',
};
function render() {
const view = routes[location.pathname] ?? (() => '<h1>Not found</h1>');
document.querySelector('#app').innerHTML = view();
}
document.addEventListener('click', (event) => {
const link = event.target.closest('a[href]');
if (
!link ||
link.origin !== location.origin ||
link.target ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey
) {
return; // external links and "open in a new tab" stay with the browser
}
event.preventDefault();
history.pushState(null, '', link.href);
render(); // pushState does not fire popstate
});
window.addEventListener('popstate', render); // back and forward
render();
The same router with the Navigation API, without click handling:
navigation.addEventListener('navigate', (event) => {
if (!event.canIntercept || event.hashChange || event.downloadRequest) return;
const { pathname } = new URL(event.destination.url);
event.intercept({
handler: async () => {
const view = routes[pathname] ?? (() => '<h1>Not found</h1>');
document.querySelector('#app').innerHTML = view();
},
});
});
The delegation itself is explained in DOM Events.
Follow-up Questions
- How is
location.href = '/about'different frompushState(null, '', '/about')? The first is a real navigation: a request, a new document, in-memory state lost; the second only adds a history entry. - How do you warn about unsaved changes before leaving?
beforeunloadfor page loads; inside the SPA, the router has to block its own navigation (in the Navigation API,event.preventDefault()innavigate). - Where do you keep the state of filters so that a link can be shared? In the
query string (
URLSearchParams), updated withreplaceState. See URL and Encoding. - How does the app restore the scroll position on back?
scrollRestoration = 'manual', the position saved inhistory.state(replaceStatebefore leaving), andscrollToafter the view is rendered. - How do you know whether
history.back()stays inside the app? The History API cannot tell:history.lengthcounts the whole tab, other sites included.navigation.canGoBackandnavigation.entries()answer it.
Common Mistakes
- Expecting
pushStateto firepopstateor to load the page. - Intercepting every
<a>click, including external links,target="_blank"and Ctrl / Cmd + click. - Storing large data or non-cloneable values in
state. - Calling
pushStatefor every keystroke in a search field instead ofreplaceState: the back button then steps through each letter. - Forgetting the server fallback, so a reload of
/aboutreturns 404.