Migrating from popstate to the Navigation API
After reading this you will be able to replace a hand-rolled router built from pushState, a popstate listener, and a document click handler with a single navigate listener on the Navigation API — keeping the old implementation as a feature-detected fallback so the migration ships safely to browsers that do not yet support the new surface.
← Back to The Navigation API
Prerequisites
Core Concept
The classic router is three independent mechanisms doing one job. pushState changes the URL but never notifies you, so after every programmatic navigation you call your render function by hand. popstate catches back and forward, but only those, and hands you a raw history.state you must defensively narrow. A capturing click listener catches link navigations, at the cost of reimplementing the browser’s rules about modified clicks, target, cross-origin, and downloads. Three channels, three chances to drift out of sync.
The Navigation API collapses all three into one navigate event that fires for programmatic navigations, traversals, and link clicks together. Migration is therefore mostly deletion: the click handler and its click-eligibility rules disappear, replaced by the event.canIntercept and event.downloadRequest fields the browser computes for you; the popstate handler disappears, folded into the same event as a traversal; and the manual “call render after pushState” dance disappears, because your own navigation.navigate() calls also fire the event. Because the API is not yet universal, you keep the old router intact behind a "navigation" in window check — the same route matcher feeds both paths, so behaviour stays identical regardless of which one runs.
Implementation
The before/after below shows the same router twice. The “before” is the familiar triad; the “after” feature-detects and either subscribes to navigate or falls back to the original code, unchanged.
// TypeScript 5.4+ — framework-agnostic, no runtime dependencies
// ─── BEFORE: pushState + popstate + click interception ───────────────────────
function legacyRouter(render: (path: string) => void): void {
// (1) Programmatic navigation must manually call render — the URL change is silent.
function navigate(path: string): void {
history.pushState({}, "", path);
render(path);
}
// (2) Link clicks: reimplement the browser's own eligibility rules by hand.
document.addEventListener("click", (e) => {
const link = (e.target as Element).closest("a");
if (!link || e.defaultPrevented) return;
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
const url = new URL(link.href);
if (url.origin !== location.origin) return;
if (link.hasAttribute("download") || link.target === "_blank") return;
e.preventDefault();
navigate(url.pathname);
});
// (3) Back/forward arrives on a separate channel entirely.
window.addEventListener("popstate", () => render(location.pathname));
render(location.pathname);
(window as any).__navigate = navigate;
}
// ─── AFTER: one navigate listener, with the legacy router as a fallback ───────
function router(render: (path: string, signal: AbortSignal) => Promise<void>): void {
if (!("navigation" in window)) {
// Unsupported engine: keep the proven legacy path verbatim.
legacyRouter((path) => void render(path, new AbortController().signal));
return;
}
navigation.addEventListener("navigate", (event: NavigateEvent) => {
if (!event.canIntercept || event.hashChange || event.downloadRequest !== null) return;
const { pathname } = new URL(event.destination.url);
event.intercept({ handler: () => render(pathname, event.signal) });
});
// Programmatic navigation now flows through the same event — no manual render call.
(window as any).__navigate = (path: string) => navigation.navigate(path);
}
Notice what left the “after” version: the entire click-eligibility block is gone, because canIntercept and downloadRequest encode those rules; the popstate listener is gone, because back and forward are just navigate events with a traverse type; and the programmatic navigate no longer calls render itself, because navigation.navigate() triggers the listener. Per-entry state that once lived in history.state moves to navigation.updateCurrentEntry({ state }) and is read back with entry.getState(), so you no longer narrow a raw any on every popstate.
Verification
Run one suite against both engines: Chromium exercises the Navigation API path, WebKit or Firefox exercises the fallback. Identical assertions passing on both proves behavioural parity.
// @playwright/test v1.44 — run with --project=chromium and --project=webkit
import { test, expect } from "@playwright/test";
test("link, programmatic nav, and back all render the right view", async ({ page }) => {
await page.goto("/");
await page.click("a[href='/about']");
await expect(page).toHaveURL(/\/about$/);
await page.evaluate(() => (window as any).__navigate("/products/7"));
await expect(page).toHaveURL(/\/products\/7$/);
await expect(page.locator("[data-view='product']")).toBeVisible();
await page.goBack();
await expect(page).toHaveURL(/\/about$/);
});
For a manual smoke test, run "navigation" in window in each browser’s console to confirm which path is active, then click an in-app link and check that no full document reload occurs on the modern path (the app root node keeps its identity) while the fallback path behaves the same to the user.
Gotchas
- Do not remove the legacy click and
popstatecode until the fallback branch is wired up — the Navigation API is absent in Safari and Firefox today, so deleting it strands those users on broken navigation. navigation.navigate(path)replaceshistory.pushStatefor your own navigations, but if any code still callspushStatedirectly it will not fire thenavigateevent, leaving that navigation unrendered — audit for straypushStatecalls during the migration.- State migrates from
history.stateto per-entry state viaupdateCurrentEntryandgetState; a half-migrated router that writes withpushStatebut reads withgetStatewill see empty state, so move both the write and the read together. - The
navigateevent fires for hash-only changes as well, which the old click handler often let through to the browser — preserve that behaviour by guarding onevent.hashChange, or you will start intercepting in-page anchors you never used to.
FAQ
Do I have to rewrite my route matcher to migrate? No. The matcher that maps a pathname to a view is independent of how navigations are detected, so both the Navigation API listener and the legacy fallback call the same matcher unchanged — only the detection layer around it changes.
What replaces my popstate handler after migrating? Nothing separate — back and forward arrive as navigate events with a navigationType of traverse, handled by the same listener as link clicks. You delete the standalone popstate listener and read entry state with getState instead of narrowing history.state.
How do I keep supporting Safari and Firefox during the migration? Feature-detect with a check for navigation in window and fall back to your existing pushState-plus-popstate router when it is absent. Both engines lack the Navigation API today, so the fallback is required, and the shared matcher keeps behaviour identical.
Does navigation.navigate replace pushState for programmatic navigations? Yes. Calling navigation.navigate updates the URL and fires the navigate event, so your interception handler renders the view automatically — unlike pushState, which changed the URL silently and forced a manual render call afterwards.
Will migrating change how the browser handles back-forward cache and scroll? It improves both — the Navigation API restores scroll and focus for you by default, and it does not require the unload listeners that historically evicted the back-forward cache, so traversals stay fast without the cross-browser popstate workarounds.
Related
- The Navigation API — the full API and the complete router this migration targets, including the fallback wiring.
- popstate Event Handling — the back/forward event you are replacing, and the pattern the fallback path preserves.
- Cross-Browser popstate Event Quirks — the engine-specific popstate inconsistencies the Navigation API is designed to eliminate.