Skip to content

History API — Client-Side Routing Without Page Reloads

DodaTech Updated 2026-06-28 6 min read

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

  1. 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.
  2. 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.
  3. Not handling popstate for the initial page load. popstate does not fire on page load. Initialize your router from window.location.pathname in DOMContentLoaded.
  4. Creating infinite history entries. Each pushState adds an entry. Use replaceState for transient states like search input or accordion toggles.
  5. Ignoring scroll position. When navigating back, restore scroll position stored in the state object.

Practice Questions

  1. What is the difference between pushState and replaceState?
  2. How does the popstate event help with browser navigation?
  3. Why must you configure the server to serve index.html for all routes?
  4. What happens when a user bookmarks an SPA URL created with pushState?
  5. 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

Does pushState work on all browsers?

Yes, pushState is supported in all modern browsers including IE10+. It is safe to use in production.

Can I use pushState with hash URLs?

pushState changes the path, not the hash. For hash-based routing, use the hashchange event instead. Mixing both is possible but not recommended.

What happens if a user directly visits a deep SPA URL?

If the server is not configured correctly, they get a 404. Configure your server to serve index.html for all routes so the SPA can initialize and parse the URL.

Is the state object persisted across sessions?

No. The state object in the history entry is cleared when the page session ends. Do not rely on state for persistent data.

Can I use pushState in a service worker?

No. The History API is only available in the window context. Service workers cannot modify the browser history.

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