History API — Client-Side Routing Without Page Reloads
In this tutorial, you will learn about History API. We cover key concepts, practical examples, and best practices to help you master this topic.
The History API lets SPAs manage URL changes via pushState and popstate without full page reloads, creating clean, shareable URLs for single-page applications.
What You'll Learn
By the end of this tutorial, you will understand how to use pushState, replaceState, and popstate to implement client-side routing, manage browser history, and handle deep linking.
Why It Matters
The History API is what makes SPAs feel like real applications. Without it, every navigation either reloads the page (destroying the SPA experience) or uses hash URLs (resulting in ugly links). The History API gives you clean URLs and working browser navigation.
Real-World Use
A project management SPA uses the History API to create clean URLs like /projects/123/tasks. Users can bookmark specific tasks, share links with colleagues, and use the browser back button — all without page reloads. The SPA intercepts navigation and renders the appropriate view.
How the History API Works
History API Flow
User clicks link to /projects/123
↓
event.preventDefault()
↓
history.pushState({projectId: 123}, '', '/projects/123')
↓
URL changes in address bar (no reload)
↓
SPA renders project detail view
↓
User clicks browser back button
↓
popstate event fires
↓
SPA reads event.state or current URL
↓
SPA renders previous view
Think of the History API like a bookmark in a book. You flip to a page (navigate), and instead of closing the book and opening a new one (page reload), you just put a bookmark at the new page. The book stays open in your hands.
pushState
Add a new entry to the browser's history stack:
// Syntax: history.pushState(state, title, url)
// state: any serializable data associated with the entry
// title: ignored by most browsers (pass empty string)
// url: new URL (must be same origin)
// Simple navigation
history.pushState({ page: 'home' }, '', '/home');
// With data
history.pushState({
userId: 123,
section: 'profile',
scrollPosition: window.scrollY
}, '', '/users/123/profile');
// Multiple history entries
history.pushState({ page: 'home' }, '', '/');
history.pushState({ page: 'about' }, '', '/about');
history.pushState({ page: 'contact' }, '', '/contact');
// History stack: [/] → [/about] → [/contact]
replaceState
Modify the current history entry without adding a new one:
// Use replaceState when you want to update URL without creating
// a new history entry
// Example: update search params as user types
const searchInput = document.getElementById('search');
searchInput.addEventListener('input', (event) => {
const query = event.target.value;
// Update URL without adding new history entry
history.replaceState({ query }, '', `/?search=${query}`);
});
// Example: track page section without new entries
window.addEventListener('scroll', () => {
const sections = ['intro', 'features', 'pricing'];
const currentSection = sections.find(section => {
const el = document.getElementById(section);
if (el) {
const rect = el.getBoundingClientRect();
return rect.top <= 100 && rect.bottom >= 100;
}
return false;
});
if (currentSection) {
history.replaceState(
{ section: currentSection },
'',
`/#${currentSection}`
);
}
});
popstate
Handle browser back/forward navigation:
// Listen for back/forward navigation
window.addEventListener('popstate', (event) => {
// event.state contains the state object from pushState/replaceState
console.log('Navigation state:', event.state);
if (event.state) {
// Use the stored state
const { page, userId } = event.state;
console.log(`Navigating to ${page} for user ${userId}`);
renderPage(page, userId);
} else {
// No state (initial page load or hash navigation)
const path = window.location.pathname;
console.log('Navigating to path:', path);
renderPageForPath(path);
}
});
// Navigate function using pushState
function navigate(path, data = {}) {
const fullPath = path.startsWith('/') ? path : `/${path}`;
// Don't navigate to same page
if (fullPath === window.location.pathname) return;
// Add to history
history.pushState(data, '', fullPath);
// Render new page
renderPage(fullPath, data);
}
// Initialize from current URL on page load
document.addEventListener('DOMContentLoaded', () => {
const path = window.location.pathname;
renderPageForPath(path);
});
Complete Router Implementation
class HistoryRouter {
constructor(routes) {
this.routes = routes;
this.currentRoute = null;
// Intercept link clicks
document.addEventListener('click', this.handleLinkClick.bind(this));
// Listen for back/forward
window.addEventListener('popstate', this.handlePopState.bind(this));
// Initialize
this.resolveRoute(window.location.pathname);
}
handleLinkClick(event) {
const link = event.target.closest('a');
if (!link) return;
const url = new URL(link.href);
// Only intercept same-origin links
if (url.origin !== window.location.origin) return;
// Don't intercept links with download or external targets
if (link.target === '_blank' || link.hasAttribute('download')) return;
event.preventDefault();
this.navigate(url.pathname + url.search);
}
navigate(path) {
history.pushState({ path }, '', path);
this.resolveRoute(path);
}
handlePopState(event) {
const path = event.state?.path || window.location.pathname;
this.resolveRoute(path);
}
resolveRoute(path) {
// Find matching route
const route = this.routes.find(r => {
if (typeof r.pattern === 'string') {
return r.pattern === path;
}
if (r.pattern instanceof RegExp) {
return r.pattern.test(path);
}
return false;
});
if (route) {
console.log('Route matched:', route.name);
route.handler(this.getParams(path, route.pattern));
} else {
this.render404(path);
}
}
getParams(path, pattern) {
if (pattern instanceof RegExp) {
const matches = path.match(pattern);
return matches ? matches.slice(1) : [];
}
return [];
}
render404(path) {
document.getElementById('app').innerHTML = `
<h1>Page Not Found</h1>
<p>No route matches "${path}"</p>
`;
}
}
// Usage
const router = new HistoryRouter([
{ name: 'home', pattern: '/', handler: () => renderHome() },
{ name: 'users', pattern: '/users', handler: () => renderUserList() },
{ name: 'user', pattern: /^\/users\/(\d+)$/, handler: (id) => renderUser(id) },
{ name: 'settings', pattern: '/settings', handler: () => renderSettings() }
]);
Server Configuration
For History API routing to work with deep links, configure your server:
// Express.js example — serve index.html for all SPA routes
const express = require('express');
const app = express();
app.use(express.static('public'));
// All routes fall through to the SPA
app.get('*', (req, res) => {
res.sendFile(__dirname + '/public/index.html');
});
// Nginx configuration
// location / {
// try_files $uri $uri/ /index.html;
// }
Common Mistakes
- Not passing state to pushState. State is optional but useful. Pass at least the path or identifying data so popstate can restore the correct view.
- Using pushState for URLs that require server rendering. If you have SSR pages and SPA pages, ensure the correct URLs map to the correct rendering mode.
- Not handling popstate for the initial page load. popstate does not fire on page load. Initialize your router from window.location.pathname in DOMContentLoaded.
- Creating infinite history entries. Each pushState adds an entry. Use replaceState for transient states like search input or accordion toggles.
- Ignoring scroll position. When navigating back, restore scroll position stored in the state object.
Practice Questions
- What is the difference between pushState and replaceState?
- How does the popstate event help with browser navigation?
- Why must you configure the server to serve index.html for all routes?
- What happens when a user bookmarks an SPA URL created with pushState?
- How do you restore the correct view when a user navigates back?
Challenge: Implement a complete router with History API that supports: parameterized routes (/users/:id), nested routes (/users/:id/posts), query string Parsing, scroll position restoration on back navigation, and 404 handling.
FAQ
Mini Project
Build a simple SPA router using the History API with four routes: / (home), /products (list), /products/:id (detail), /cart. Implement link interception, popstate handling, and a fallback for unmatched routes. Add a server configuration snippet for Express that ensures deep links work.
What's Next
You mastered the History API. Now learn hash routing — an alternative approach that does not require server configuration.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro