DEV Community

Weather Clock Dash
Weather Clock Dash

Posted on

History API for SPAs: pushState, popstate, Scroll Restoration, URLSearchParams, and Nginx Config

The History API allows JavaScript to manipulate the browser's navigation history and URL without full page reloads. It's the mechanism that makes single-page applications (SPAs) feel like multi-page apps — different URLs for different views, working back/forward buttons, and bookmarkable states. Firefox implements the History API fully, and understanding it helps you build better SPAs and debug routing issues in Firefox DevTools.

The Core Problem History API Solves

Before the History API (HTML5), SPAs used the URL hash (#/users/123) for navigation. Hash changes don't trigger page reloads and don't affect the server, but they look ugly and don't work well with server-side rendering.

The History API allows changing the full URL path (/users/123) without a page reload, while maintaining proper browser history.

pushState and replaceState

// Add new history entry (creates a new back-button step)
history.pushState(stateObject, '', '/users/123');

// Replace current history entry (no new back-button step)
history.replaceState(stateObject, '', '/users/123');
Enter fullscreen mode Exit fullscreen mode

Parameters:

  1. State object: serializable object associated with this history entry (max 16MB in Firefox). Accessible later via history.state.
  2. Title: mostly ignored by browsers. Pass ''.
  3. URL: new URL (must be same origin). Can be absolute or relative path.

After calling pushState('/users/123'), the address bar shows /users/123, but no network request is made. The browser just updates the address bar and adds an entry to history.

Reading the Current State

// Current URL
location.pathname  // '/users/123'
location.search    // '?tab=info'
location.hash      // '#section'
location.href      // full URL

// Current history state (from the last pushState/replaceState)
history.state      // the state object you passed
history.length     // number of entries in history for this session
Enter fullscreen mode Exit fullscreen mode

The popstate Event

popstate fires when the user navigates back/forward OR when history.back()/history.go() is called programmatically.

window.addEventListener('popstate', (event) => {
  console.log('Navigated to:', location.pathname);
  console.log('State:', event.state);

  // Re-render the current view
  renderPage(location.pathname, event.state);
});
Enter fullscreen mode Exit fullscreen mode

Critical: popstate does NOT fire when pushState or replaceState is called. Only fires on back/forward navigation.

Building a Simple Router

class Router {
  constructor(routes) {
    this.routes = routes; // { '/': homeView, '/about': aboutView, ... }

    // Handle back/forward
    window.addEventListener('popstate', () => this.render(location.pathname));

    // Handle link clicks
    document.addEventListener('click', (e) => {
      const link = e.target.closest('a[href^="/"]');
      if (link && !link.hasAttribute('target') && !e.ctrlKey && !e.metaKey) {
        e.preventDefault();
        this.navigate(link.getAttribute('href'));
      }
    });
  }

  navigate(path) {
    history.pushState({}, '', path);
    this.render(path);
  }

  render(path) {
    const route = this.routes[path] || this.routes['/404'];
    if (route) route();
  }

  start() {
    this.render(location.pathname);
  }
}

const router = new Router({
  '/': () => { document.getElementById('app').innerHTML = '<h1>Home</h1>'; },
  '/about': () => { document.getElementById('app').innerHTML = '<h1>About</h1>'; },
  '/404': () => { document.getElementById('app').innerHTML = '<h1>404</h1>'; }
});

router.start();
Enter fullscreen mode Exit fullscreen mode

Server Configuration for SPA Routing

When using pushState, the server must serve your index.html for all routes. Otherwise, directly navigating to /users/123 returns a 404 because the server has no file at that path.

Nginx:

location / {
  try_files $uri $uri/ /index.html;
}
Enter fullscreen mode Exit fullscreen mode

Apache:

<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteBase /
  RewriteRule ^index\.html$ - [L]
  RewriteCond %{REQUEST_FILENAME} !-f
  RewriteCond %{REQUEST_FILENAME} !-d
  RewriteRule . /index.html [L]
</IfModule>
Enter fullscreen mode Exit fullscreen mode

Vite dev server: automatically handles this in development.

Saving Scroll Position

When navigating back, users expect to return to their previous scroll position:

// Save scroll position before navigating away
window.addEventListener('beforeunload', () => {
  history.replaceState({ ...history.state, scrollY: window.scrollY }, '', location.href);
});

// Restore on popstate
window.addEventListener('popstate', (event) => {
  renderPage(location.pathname);
  // After rendering, restore scroll
  if (event.state?.scrollY !== undefined) {
    window.scrollTo(0, event.state.scrollY);
  }
});
Enter fullscreen mode Exit fullscreen mode

URL Parameters

The History API doesn't parse URL parameters — use URLSearchParams:

// Read parameters from current URL
const params = new URLSearchParams(location.search);
const page = params.get('page');     // '2'
const sort = params.get('sort');     // 'date'

// Build new URL with updated params
params.set('page', currentPage + 1);
history.pushState({}, '', `${location.pathname}?${params.toString()}`);
Enter fullscreen mode Exit fullscreen mode

URLSearchParams is the correct way to handle query strings. Don't use string manipulation with .split('=') and .split('&') — it doesn't handle encoding correctly.

Debugging History in Firefox

DevTools doesn't have a History tab, but you can inspect history state:

// In DevTools console
history.state          // current state object
history.length         // stack length

// Listen for all navigation
window.addEventListener('popstate', console.log);
Enter fullscreen mode Exit fullscreen mode

The address bar in Firefox updates immediately on pushState — watch it when debugging navigation.

history.scrollRestoration

Controls whether the browser automatically restores scroll position on back navigation:

// Disable browser's automatic scroll restoration (manage it yourself)
history.scrollRestoration = 'manual';

// Restore browser's automatic scroll restoration
history.scrollRestoration = 'auto'; // default
Enter fullscreen mode Exit fullscreen mode

SPAs often set this to 'manual' to control scroll restoration behavior precisely — otherwise the browser may scroll to an incorrect position after re-rendering async content.

Top comments (0)