12k
All articles

Building Client-Side Routing with the History API

Build a vanilla History API router with pushState, popstate, dynamic params, SEO-friendly URLs, and fixes for refresh 404s and XSS risks.

OpenReplay Team
OpenReplay Team
Building Client-Side Routing with the History API

Client-side routing swaps views by updating the URL and re-rendering in JavaScript, with no round-trip to the server (the server is only contacted on the very first load or a hard refresh).

If you have ever shipped a single-page app, you know the moment: everything works locally, then a teammate hits the Back button and the URL changes while the page just sits there, unmoved. It takes five minutes to fix once you know where to look, and it catches almost everyone the first time.

Frameworks like React Router and Vue Router wrap this behavior in components and hooks, but underneath they all drive the same browser primitive: the History API. This article builds a minimal, correct, deployable vanilla router in about 50 lines, explains the pushState/popstate division of labor, and covers the two gotchas (the deployment 404 and the XSS injection risk) that separate a toy from something you can ship.

Key Takeaways

  • In History mode, history.pushState(state, '', url) changes the URL without a page reload, but it does not fire a popstate event. You call your render function yourself after pushState, and separately listen for popstate to handle Back and Forward.
  • History mode’s clean /dashboard URLs are better for SEO and sharing, but they require the server to rewrite every unknown path to index.html, or a direct visit or refresh returns a 404.
  • The second argument to pushState is a legacy title parameter browsers ignore; it cannot be omitted, so always pass an empty string.
  • Injecting a view with innerHTML is an XSS vector for any interpolated untrusted data and silently drops event listeners on the injected markup. Build nodes with createElement, sanitize, or use a templating library, and attach behavior via event delegation.
  • The Navigation API reached Baseline Newly available in January 2026 and is the emerging successor to this pattern, but the History API remains the widest-compatibility baseline.

What’s the difference between hash mode and History mode?

Client-side routing updates the view when the URL changes without a full page reload. There are two ways to change the URL without navigating: hash mode and History mode. Hash mode encodes the route after a # (/app#/users). The fragment after the hash is never sent to the server, so hash-based navigation is purely client-side and needs zero server configuration, and you listen for the hashchange event. History mode produces clean paths (/users) using the History API and listens for popstate.

Hash modeHistory mode
URL shape/app#/users/users
Change eventhashchangepopstate
Server configNoneRewrite all paths to index.html
Refresh / deep linkAlways works404 without rewrite
SEO / shareable URLsWeakerCleaner, preferred

History mode is the default choice for its clean, indexable URLs, and it is what this article builds in. The one cost is that it needs server support, covered below.

The History API primitives you actually need

Three primitives carry a History-mode router. history.pushState(state, unused, url) adds an entry to the session history stack and changes the address bar; history.replaceState does the same but overwrites the current entry instead of adding one. location.pathname reads the current path so you can match a route. The popstate event fires when the user presses Back or Forward.

The critical rule: pushState and replaceState do not fire popstate. You must call your render function yourself after every pushState, and separately register a popstate listener so browser Back and Forward re-render the view. Miss the listener and the URL changes on Back while the DOM stays frozen, a bug that is invisible in code review but obvious the moment you watch a session replay of the app.

Two more details matter. The middle argument is a legacy title value that browsers ignore, and it cannot be omitted, so pass an empty string. The url must be same-origin: the browser does not load it when you call pushState, and the call throws if the origin differs from the current page. popstate itself is old and reliable, available across browsers since July 2015.

How do you build a minimal router?

A working History-mode router needs five parts: a routes map, a resolve function that reads location.pathname and matches a route with a 404 fallback, click delegation on a data-link attribute, a popstate listener, and an initial render. Here is the complete file:

function escapeHtml(str) {
  return String(str).replace(/[&<>"']/g, (c) =>
    ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
}

const routes = {
  '/':          { view: () => '<h1>Home</h1><a href="/users/42" data-link>User 42</a>', title: 'Home' },
  '/users/:id': { view: (p) => `<h1>User ${escapeHtml(p.id)}</h1>`, title: 'User' },
  '/404':       { view: () => '<h1>404 — Not found</h1>', title: 'Not found' },
};

const app = document.getElementById('app');

function match(pathname) {
  for (const pattern of Object.keys(routes)) {
    const pParts = pattern.split('/');
    const uParts = pathname.split('/');
    if (pParts.length !== uParts.length) continue;
    const params = {};
    const ok = pParts.every((part, i) => {
      if (part.startsWith(':')) { params[part.slice(1)] = decodeURIComponent(uParts[i]); return true; }
      return part === uParts[i];
    });
    if (ok) return { route: routes[pattern], params };
  }
  return { route: routes['/404'], params: {} };
}

function resolve() {
  const { route, params } = match(location.pathname);
  app.innerHTML = route.view(params);
  document.title = route.title;
}

function navigate(url) {
  history.pushState({}, '', url);   // '' is the ignored legacy title
  resolve();                        // pushState does NOT fire popstate — render manually
}

document.addEventListener('click', (e) => {
  const link = e.target.closest('[data-link]');   // robust: works on nested markup
  if (!link) return;
  e.preventDefault();
  navigate(link.getAttribute('href'));
});

window.addEventListener('popstate', resolve);      // Back / Forward

history.replaceState({}, '', location.pathname);    // seed the initial entry
resolve();                                          // render on first paint

Event delegation via e.target.closest('[data-link]') is deliberate. It survives clicks on child nodes (an icon inside a link) and keeps working when views are re-rendered, unlike attaching listeners to each element or reading e.target.attributes[0], which depends on attribute order and breaks on nested markup.

Level up: dynamic params, titles, and the initial entry

The match function above already handles dynamic segments. A pattern like /users/:id splits into parts; any segment beginning with : captures the corresponding path segment into a params object, so /users/42 resolves with { id: '42' }. Non-: segments must match exactly, and a length mismatch skips the pattern, which keeps /users from matching /users/42. Setting document.title inside resolve updates the tab and history label on every navigation.

One more fix belongs in the router. The browser creates your first history entry from an ordinary page load, so nothing is stored on it, and the MDN guide to working with the History API recommends calling history.replaceState() at startup to attach state to that entry. Do that and the first Back press can restore your opening view. That is the final replaceState line in the router.

The two gotchas that separate a toy from a real router

Deployment. History mode’s clean URLs require the server to rewrite every unknown path to index.html, or a direct visit or refresh to /users/42 returns a 404. There is no JavaScript workaround, because the request reaches the server before your bundle loads. Configure the rewrite once per host. Express 5 changed its path matching syntax: every wildcard now has to be named, so the old catch-all app.get('*') throws a “Missing parameter name” error at startup. Use the braced named wildcard, which matches the root path as well as everything below it:

// Express 5.x
app.get('/{*splat}', (req, res) => res.sendFile(__dirname + '/public/index.html'));
// Express 4.x used: app.get('*', ...)
# Nginx
location / { try_files $uri $uri/ /index.html; }
# Netlify — _redirects
/*  /index.html  200
// Vercel — vercel.json
{ "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] }

A hard-refresh 404 on a shared deep link is another failure that reads fine in code but shows up plainly when you watch a real session land on a blank page.

Security. Injecting a view with innerHTML is an XSS vector whenever untrusted data is interpolated (the ${p.id} above comes straight from the URL), and it silently drops event listeners on the injected markup. Escape interpolated values (the escapeHtml call above), build nodes with document.createElement, or use a templating library such as lit-html, and attach behavior through event delegation on a stable parent rather than on the injected nodes. Static, developer-authored template strings with no interpolation are not themselves an injection; the risk is the untrusted data you splice in.

Where the platform is heading: the Navigation API

The Navigation API reached Baseline Newly available in January 2026, the month Firefox 147 added support, and it is the emerging successor to this pattern. Instead of wiring up pushState, a popstate listener, and a click handler separately, you register one navigate listener. It runs for every navigation the page can see, whatever started it, and calling event.intercept() inside that listener leaves the address bar and the history stack to the browser. One of the shortcomings it addresses is that popstate does not fire on programmatic pushState/replaceState, the exact friction this router works around. Until it is the compatibility floor for your target browsers, the History API remains the widest-support baseline and the clearest way to understand what a router actually does.

You now have a runnable History-mode router: routes, param matching, delegated clicks, correct popstate handling, a seeded initial entry, and both production fixes. The next concrete step is wiring the server rewrite for your host before you deploy, so deep links survive a refresh.

FAQs

Why does the Back button change the URL but leave the page unchanged in my SPA?

Because pushState and replaceState do not fire a popstate event, so if you only render inside your click handler and never register a popstate listener, Back and Forward update the address bar without re-rendering. The fix is a separate window.addEventListener('popstate', resolve) that runs your render function whenever the browser moves through history. Watch a session replay and you will see a URL change with no DOM change.

What is the difference between pushState and replaceState?

pushState adds a new entry to the session history stack, so the previous view stays reachable with the Back button. replaceState overwrites the current entry instead of adding one, so it does not create a new Back target. Use pushState for normal navigation and replaceState to seed the initial page entry on startup or to correct the current URL without polluting history. Both share the same (state, unused, url) signature and neither fires popstate.

Does hash-mode routing need any server configuration?

No. The fragment after the hash, such as the '/users' in '/app#/users', is never sent to the server, so hash-based navigation is purely client-side and works on any static host with zero rewrite rules. Refreshes and deep links always resolve because the server only ever sees '/app'. History mode is the tradeoff: it produces cleaner URLs but requires the server to rewrite every unknown path to index.html or a refresh returns a 404.

Should I still learn the History API now that the Navigation API is Baseline?

Yes. The Navigation API reached Baseline Newly available in January 2026 and is the emerging successor, replacing manual pushState, popstate, and click interception with a single navigate event and event.intercept(). But the History API remains the widest-compatibility baseline, works in older browsers the Navigation API does not, and is what frameworks like React Router and Vue Router still drive underneath. Learning it is the clearest way to understand what any router actually does.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.