Client-Side Routing

Client-Side Routing and the History API

A router is less magical than it looks. The browser already lets a page change its address without fetching anything: history.pushState(state, "", url) adds an entry to the session history and rewrites the address bar, and history.replaceState overwrites the current entry instead. The second argument is a legacy title slot that must be empty, and the URL has to be same-origin.

The other half is going backward. pushState fires no event — the code that pushed already knows — but Back and Forward fire popstate on window, carrying the state stored with that entry. A hand-rolled router is those two pieces plus a render.

A router in a dozen lines, with no library at allJavaScript
const routes = { '/': () => 'Home page', '/about': () => 'About us' };
const render = () => {
  const view = routes[location.pathname] || (() => '404: ' + location.pathname);
  document.querySelector('#app').textContent = view();
};
document.addEventListener('click', (e) => {
  const a = e.target.closest('a[href^="/"]');
  if (!a || e.metaKey || e.ctrlKey || a.target) return;   // let the browser handle these
  e.preventDefault();                                     // no navigation, no reload
  history.pushState({}, '', a.getAttribute('href'));      // address bar only
  render();
});
window.addEventListener('popstate', render);              // Back and Forward
render();

That is the whole idea, and also why you should not ship it. Those lines have no pattern matching, so /products/42 cannot be a route; no nesting, so a shared layout re-renders from scratch; no relative links; no scroll restoration; and the click handler quietly breaks download and rel="external". React Router 139,386 is what this grows into once every case is handled.

A newer API, the Navigation API, models navigation directly — navigation.navigate(), an intercepting navigate event, a real list of entries — and became Baseline in January 2026. React Router still builds on history.