History API and Routing Basics
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.
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'
);
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-pathto/new-path)
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);
}
});
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"
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);
});
| Feature | Hash Routing | History Routing |
|---|---|---|
| URLs | example.com/#/about | example.com/about |
| Server config | None needed | Needs catch-all route (serve index.html for all paths) |
| SEO | Worse — crawlers may ignore hash | Better — clean URLs |
| Browser support | All browsers | All modern browsers |
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.
- 1pushState adds a history entry without reloading — use it for genuine navigation
- 2replaceState modifies the current entry — use it for URL updates that shouldn't create back-button entries
- 3popstate fires on back/forward, NOT on pushState/replaceState calls
- 4Use URLSearchParams for query string manipulation — never parse query strings manually
- 5History routing needs a server catch-all route to handle direct navigation and refreshes
| What developers do | What 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) |