Skip to content

History API and Routing Basics

beginner13 min read

Why the Back Button Is Complicated

In a traditional website, every page is a separate HTML file. Click a link, the browser navigates to a new URL, fetches a new document, and adds an entry to the browser's history stack. The back button just pops that stack. Simple.

But in a Single Page Application (SPA), there's only one HTML document. When you "navigate" between pages, JavaScript swaps out the content without a full page reload. The problem? The browser doesn't know you changed pages. The URL stays the same. The back button does nothing. The user can't bookmark a specific view.

The History API solves this. It lets JavaScript update the URL and manage the browser's history stack without triggering a page reload. Every SPA router you've ever used — React Router, Next.js, Vue Router, SvelteKit — is built on top of this API.

Mental Model

Think of the browser's history as a stack of cards, each card representing a page visit. When you navigate, a new card goes on top. The back button removes the top card. In a traditional site, the browser manages the cards. In an SPA, you manage the cards using pushState (add a card), replaceState (rewrite the top card), and popstate (the user pressed back or forward, so a card was removed or restored). You're manually maintaining the illusion of navigation.

pushState — Add a History Entry

pushState adds a new entry to the browser's history stack and updates the URL — without navigating away from the current page.

// Syntax: pushState(state, unused, url)
history.pushState({ page: 'about' }, '', '/about');

After this call:

  • The URL bar shows /about
  • A new entry is added to the history stack
  • The back button now goes to the previous URL
  • No page reload happens — your JavaScript is still running
// Navigate to different "pages"
history.pushState({ page: 'home' }, '', '/');
history.pushState({ page: 'products' }, '', '/products');
history.pushState({ page: 'contact' }, '', '/contact');

// Now the history stack has 3 entries
// Back button goes: /contact → /products → /

The State Object

The first argument is a state object that you can retrieve later when the user navigates back. It's stored by the browser and associated with that history entry.

history.pushState(
  { page: 'product', id: 42, scrollY: window.scrollY },
  '',
  '/products/42'
);
Common Trap

The state object is serialized using the structured clone algorithm, which means it can hold most JavaScript values (objects, arrays, dates, maps, sets) but NOT functions, DOM elements, or class instances with methods. Also, most browsers limit the state object to about 2-16 MB. Keep it small — store just the data you need to reconstruct the view.

The Unused Second Parameter

The second parameter (often called "title") is officially unused by all browsers. Pass an empty string. It exists for historical reasons and may be used in the future.

replaceState — Rewrite the Current Entry

replaceState works like pushState but doesn't add a new entry — it replaces the current one. The back button doesn't change.

// User is at /products, we redirect to /products?sort=price
history.replaceState({ sort: 'price' }, '', '/products?sort=price');

// The URL changed, but the history stack didn't grow
// Back button still goes wherever it went before

When to use replaceState:

  • Redirects (you don't want the intermediate URL in history)
  • Updating query parameters without creating a back-button entry
  • Correcting the URL after a redirect (e.g., /old-path to /new-path)
Quiz
What is the difference between pushState and replaceState?

popstate — Handling Back/Forward Navigation

The popstate event fires when the user clicks the back or forward button (or calls history.back()/history.forward()). It does NOT fire when you call pushState or replaceState.

window.addEventListener('popstate', (event) => {
  // event.state is the state object from pushState/replaceState
  console.log('Navigated to:', document.location.href);
  console.log('State:', event.state);

  // Render the correct content based on the URL or state
  if (event.state) {
    renderPage(event.state.page);
  }
});
Common Trap

popstate only fires for same-document navigation. If the user clicks back to a different website or a different HTML document on your site, popstate doesn't fire — the browser does a full navigation instead. popstate is specifically for history entries created with pushState/replaceState.

The URL Object

JavaScript gives you a proper URL object for parsing and manipulating URLs without error-prone string operations:

const url = new URL('https://example.com/products?sort=price&page=2#reviews');

url.origin;     // "https://example.com"
url.protocol;   // "https:"
url.hostname;   // "example.com"
url.pathname;   // "/products"
url.search;     // "?sort=price&page=2"
url.hash;       // "#reviews"
url.href;       // the full URL string

// Modify parts
url.pathname = '/categories';
url.hash = '#top';
console.log(url.href); // "https://example.com/categories?sort=price&page=2#top"

URLSearchParams — Query String Made Easy

URLSearchParams handles the ?key=value&key2=value2 part of URLs:

const params = new URLSearchParams('sort=price&page=2&tag=sale');

params.get('sort');     // "price"
params.get('page');     // "2" (string)
params.get('missing');  // null
params.has('tag');      // true

// Modify
params.set('page', '3');           // update existing
params.append('tag', 'clearance'); // add another value for same key
params.delete('sort');             // remove

params.toString(); // "page=3&tag=sale&tag=clearance"

// Iterate
for (const [key, value] of params) {
  console.log(key, value);
}

Getting Params from the Current URL

// From the current page URL
const params = new URLSearchParams(window.location.search);
const query = params.get('q');

// From any URL
const url = new URL('https://example.com/search?q=javascript&lang=en');
url.searchParams.get('q'); // "javascript"
Quiz
What does new URLSearchParams('a=1&b=2&a=3').getAll('a') return?

Hash-Based vs History-Based Routing

SPAs have used two approaches to client-side routing:

Hash Routing (older approach)

Uses the URL hash (#) to represent routes. The hash never causes a page reload.

// URLs look like: example.com/#/about, example.com/#/products
window.addEventListener('hashchange', () => {
  const route = window.location.hash.slice(1); // remove the #
  renderRoute(route);
});

// Navigate by changing the hash
window.location.hash = '#/about';

History Routing (modern approach)

Uses the History API for clean URLs. No hash needed.

// URLs look like: example.com/about, example.com/products
function navigate(path) {
  history.pushState(null, '', path);
  renderRoute(path);
}

window.addEventListener('popstate', () => {
  renderRoute(window.location.pathname);
});
FeatureHash RoutingHistory Routing
URLsexample.com/#/aboutexample.com/about
Server configNone neededNeeds catch-all route (serve index.html for all paths)
SEOWorse — crawlers may ignore hashBetter — clean URLs
Browser supportAll browsersAll modern browsers
Server configuration for history routing

With history-based routing, if a user visits example.com/about directly (or refreshes the page), the server receives a request for /about. If your server only serves index.html at /, it returns a 404. You need to configure your server to serve index.html for all paths, letting the client-side router handle the actual routing. This is sometimes called a "catch-all" or "fallback" route.

How SPA Routers Work (Simplified)

Here's the basic mechanism that React Router, Vue Router, and every other SPA router uses under the hood:

function createRouter(routes) {
  function renderCurrentRoute() {
    const path = window.location.pathname;
    const route = routes.find(r => r.path === path);

    if (route) {
      document.getElementById('app').innerHTML = '';
      route.render(document.getElementById('app'));
    }
  }

  // Handle back/forward buttons
  window.addEventListener('popstate', renderCurrentRoute);

  // Handle link clicks
  document.addEventListener('click', (e) => {
    const link = e.target.closest('a[data-link]');
    if (!link) return;

    e.preventDefault();
    history.pushState(null, '', link.href);
    renderCurrentRoute();
  });

  // Initial render
  renderCurrentRoute();
}

createRouter([
  { path: '/', render: (el) => { el.textContent = 'Home'; }},
  { path: '/about', render: (el) => { el.textContent = 'About'; }},
]);

That's the core loop: intercept link clicks, call pushState, render the matching component, and listen for popstate to handle back/forward. Everything else in real routers — route params, nested routes, lazy loading, transitions, guards — is built on top of this foundation.

Quiz
A user directly navigates to example.com/products in a history-based SPA. The server returns a 404. What configuration is needed?
Key Rules
  1. 1pushState adds a history entry without reloading — use it for genuine navigation
  2. 2replaceState modifies the current entry — use it for URL updates that shouldn't create back-button entries
  3. 3popstate fires on back/forward, NOT on pushState/replaceState calls
  4. 4Use URLSearchParams for query string manipulation — never parse query strings manually
  5. 5History routing needs a server catch-all route to handle direct navigation and refreshes
What developers doWhat they should do
Expecting popstate to fire when calling pushState
popstate only fires when the user navigates with the back/forward buttons or when history.back()/forward() is called. pushState and replaceState do not fire popstate — you need to update the UI yourself after calling them
Manually updating the UI after pushState, and using popstate only for back/forward
Parsing URLs with string splitting and regex
String-based URL parsing is fragile and breaks on edge cases (encoded characters, missing parts, unusual formats). The URL and URLSearchParams APIs handle all edge cases correctly
Using the URL constructor and URLSearchParams
Storing DOM elements or functions in the pushState state object
The state object is serialized with the structured clone algorithm, which cannot handle functions, DOM nodes, or class instances. Store IDs and minimal data, then reconstruct the UI from that data
Storing only serializable data (strings, numbers, plain objects)