The Navigation API
For two decades the only way to build a client-side router was to bolt three unrelated primitives together: history.pushState to change the URL, a global popstate listener to catch the back and forward buttons, and a document-level click handler to intercept in-page links. The Navigation API (window.navigation) replaces that scattered arrangement with a single, coherent surface: one event that fires for every same-document navigation, a first-class way to take ownership of it, and a proper list of history entries you can read and annotate. This page explains the API in depth and gives you a framework-agnostic TypeScript router built on it, with a History API fallback for the browsers that do not yet ship it.
← Back to History API & State Management
The Problem
The classic single-page-application router is an accretion of workarounds, and each piece has a sharp edge. The History API mutates the URL but tells you nothing when it changes, so you subscribe to popstate — an event that fires only for user-driven back and forward moves, never for your own pushState calls, forcing you to invoke your render logic manually after every programmatic navigation. Links are worse still: there is no navigation event for an ordinary <a> click, so every router installs a capturing click listener, then reimplements the browser’s own rules about which clicks to honour — ignore modified clicks, ignore target="_blank", ignore cross-origin, ignore downloads, honour the middle button’s default. Miss one branch and you either hijack a navigation you should have let through or leak a full page load you meant to intercept.
Three problems fall out of this design. First, there is no single choke point: back/forward, programmatic navigation, and link clicks each arrive through a different channel, so state, scroll, and focus handling get duplicated and drift out of sync. Second, you cannot cleanly own an asynchronous transition. When a route needs to fetch data before it renders, the History API gives you no way to tell the browser “a navigation is in progress” — the URL flips instantly, the loading indicator is yours to fake, and the back button during that window does something undefined. Third, the browser stops helping you. Because you intercepted the click yourself, the platform no longer manages scroll restoration or focus reset for the new view; you inherit all of it, and most routers get accessibility subtly wrong as a result.
The Navigation API was designed to answer exactly these complaints. It exposes a single navigate event for same-document navigations of every kind — link clicks, form submissions, history.back(), and its own navigation.navigate() calls — and lets you claim that navigation with one method call. When you do, the browser keeps managing the parts it is good at.
Core API & Primitives
Everything hangs off the global window.navigation object, an instance of Navigation. Its centre of gravity is the navigate event, whose event object is a NavigateEvent.
// TypeScript 5.x — DOM lib types via "dom" in tsconfig "lib"; Navigation API types ship in TS 5.4+
interface RouterHandlers {
// Fired once for every same-document navigation the browser is about to perform.
navigate(event: NavigateEvent): void;
}
// Shape of the NavigateEvent surface this guide relies on:
// event.destination.url — the target URL as a string
// event.canIntercept — false for cross-origin, downloads, and some cases you must not touch
// event.hashChange — true when only the fragment changed
// event.downloadRequest — non-null when the anchor carried a `download` attribute
// event.userInitiated — true for a genuine user gesture
// event.navigationType — "push" | "replace" | "reload" | "traverse"
// event.signal — an AbortSignal cancelled if the navigation is superseded
// event.intercept(opts) — take ownership; opts.handler returns a Promise for the transition
The intercept() call is the pivot of the whole API. You pass it an options object with a handler — an async function whose returned promise defines the lifetime of the navigation. The URL updates, navigation.transition becomes non-null, and the browser shows its native loading affordance until your promise settles.
// TypeScript 5.x — framework-agnostic, no runtime dependencies
navigation.addEventListener("navigate", (event) => {
if (!event.canIntercept || event.hashChange || event.downloadRequest) return;
event.intercept({
handler: async () => {
const view = await resolveRoute(new URL(event.destination.url).pathname);
await view.render();
},
// "after-transition" (default), "manual", or "none" — see scroll/focus below
focusReset: "after-transition",
scroll: "after-transition",
});
});
The remaining primitives let you read and shape history. navigation.entries() returns an array of NavigationHistoryEntry objects — the full same-document back/forward list, something the History API never exposed. navigation.currentEntry is the entry you are on, and each entry carries a structured-cloned state you read with entry.getState(). To amend the current entry’s state or URL without pushing a new one, call navigation.updateCurrentEntry({ state }). During an interception, navigation.transition gives you a handle on the in-flight navigation, including a finished promise and the from entry you are leaving.
// TypeScript 5.x — reading and annotating history entries
const entries = navigation.entries(); // NavigationHistoryEntry[]
const here = navigation.currentEntry; // NavigationHistoryEntry | null
const saved = here?.getState() as { scrollY?: number } | undefined;
// Amend the current entry in place — no new history entry, no navigation.
navigation.updateCurrentEntry({ state: { ...saved, scrollY: window.scrollY } });
Step-by-Step Implementation
The steps below assemble a complete router on top of the Navigation API and then wrap it so it degrades to a pushState and popstate implementation where the API is absent. The prerequisite is a Chromium-based browser (Chrome or Edge 102+) for first-class support; every step is written so the fallback path in Step 5 covers the rest.
Step 1: Feature-detect and define the route table
Detection is a single truthy check for window.navigation. Keep the route table independent of the API so the same matcher serves both the modern and fallback paths.
// TypeScript 5.x — framework-agnostic, no runtime dependencies
type RouteView = { title: string; render: (params: Record<string, string>) => Promise<void> };
type Route = { pattern: URLPattern; view: RouteView };
const supportsNavigationAPI = "navigation" in window;
const routes: Route[] = [
{ pattern: new URLPattern({ pathname: "/" }), view: homeView },
{ pattern: new URLPattern({ pathname: "/products/:id" }), view: productView },
{ pattern: new URLPattern({ pathname: "/about" }), view: aboutView },
];
function resolve(pathname: string): { view: RouteView; params: Record<string, string> } | null {
for (const route of routes) {
const match = route.pattern.exec({ pathname });
if (match) return { view: route.view, params: match.pathname.groups as Record<string, string> };
}
return null;
}
Step 2: Register the single navigate listener
One listener replaces the click handler, the form handler, and the popstate handler simultaneously. Guard first, then intercept — everything you do not intercept is left to the browser, which is exactly what you want for cross-origin links and downloads.
// TypeScript 5.x — framework-agnostic, no runtime dependencies
navigation.addEventListener("navigate", (event) => {
// Only own navigations we can and should handle in-document.
if (!event.canIntercept) return; // cross-origin, or otherwise off-limits
if (event.downloadRequest !== null) return; // let the browser download the file
if (event.hashChange) return; // same-document fragment scroll — leave it
const url = new URL(event.destination.url);
const matched = resolve(url.pathname);
if (!matched) return; // fall through to a real navigation / 404 doc
event.intercept({
handler: () => renderView(matched.view, matched.params, event.signal),
});
});
Step 3: Render inside the handler and honour the abort signal
The handler’s promise defines the transition. Because event.signal aborts when a newer navigation supersedes this one, thread it into your data fetch so an impatient user tapping through three links does not paint stale content.
// TypeScript 5.x — framework-agnostic, no runtime dependencies
async function renderView(
view: RouteView,
params: Record<string, string>,
signal: AbortSignal,
): Promise<void> {
const data = await fetchViewData(view, params, signal); // pass signal to fetch()
if (signal.aborted) return; // superseded — abandon quietly
document.title = view.title;
await view.render(params);
}
Step 4: Persist and restore per-entry state
Instead of a side table keyed by URL, attach state directly to the history entry. Save scroll position as you leave an entry and read it back with getState() when you return — the entry travels with the back/forward stack automatically.
// TypeScript 5.x — framework-agnostic, no runtime dependencies
// Before a navigation leaves the current entry, stamp the scroll position onto it.
navigation.addEventListener("navigate", () => {
const state = (navigation.currentEntry?.getState() as { scrollY?: number }) ?? {};
navigation.updateCurrentEntry({ state: { ...state, scrollY: window.scrollY } });
}, { capture: true }); // capture so it runs before the interception handler above
// When a traversal lands, restore what the entry remembers.
function restoreScroll(): void {
const state = navigation.currentEntry?.getState() as { scrollY?: number } | undefined;
if (state?.scrollY != null) window.scrollTo(0, state.scrollY);
}
Step 5: Wrap it with a History API fallback
Expose a uniform navigate() and start() regardless of engine. On the fallback path you rebuild the old triad — pushState, a popstate listener, and click interception — behind the same interface, so the rest of the application never learns which path it is on.
// TypeScript 5.x — framework-agnostic, no runtime dependencies
function startRouter(): void {
if (supportsNavigationAPI) {
// The navigate listener from Step 2 already covers links, forms and traversals.
navigation.addEventListener("navigatesuccess", restoreScroll);
return;
}
// Fallback: the classic History API pattern behind the same behaviour.
const go = (pathname: string) => {
const matched = resolve(pathname);
if (matched) void renderView(matched.view, matched.params, new AbortController().signal);
};
document.addEventListener("click", (e) => {
const link = (e.target as Element).closest("a");
if (!link || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey) return;
const url = new URL(link.href);
if (url.origin !== location.origin || link.hasAttribute("download")) return;
e.preventDefault();
history.pushState({}, "", url.pathname);
go(url.pathname);
});
window.addEventListener("popstate", () => go(location.pathname));
go(location.pathname);
}
Verification & Testing
Drive the router with Playwright rather than synthetic events, because intercept(), scroll restoration, and the transition lifecycle only behave correctly through the browser’s real navigation machinery. Run the suite against Chromium, where the API is enabled by default.
// @playwright/test v1.44 — run with --project=chromium
import { test, expect } from "@playwright/test";
test("navigate event owns in-app link clicks", async ({ page }) => {
await page.goto("/");
await page.click("a[href='/about']");
await expect(page).toHaveURL(/\/about$/);
await expect(page.locator("[data-view='about']")).toBeVisible();
// No full document load occurred: the original app root is still the same node.
const reused = await page.evaluate(() => (window as any).__appMountCount);
expect(reused).toBe(1);
});
test("back traversal restores per-entry scroll state", async ({ page }) => {
await page.goto("/products/42");
await page.mouse.wheel(0, 1200);
await page.click("a[href='/about']");
await page.goBack();
await expect(page).toHaveURL(/\/products\/42$/);
expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(1000);
});
For a quick console check, run navigation.entries().map(e => e.url) after a few in-app clicks: you should see the same-document back/forward list grow, and navigation.currentEntry.getState() should return the state you stamped in Step 4.
Performance Tuning
- Thread
event.signalinto every fetch. The API cancels the signal the moment a newer navigation supersedes the current one; a router that ignores it wastes bandwidth on responses it will discard and risks painting a view the user already navigated away from. - Let the platform restore scroll and focus. The default
scroll: "after-transition"andfocusReset: "after-transition"are cheaper and more correct than a hand-rolled equivalent, and they run at the right moment in the frame. Only switch to"manual"when you genuinely need custom timing. - Keep entry state small and clone-safe.
getState()returns a structured clone, so oversized payloads cost serialisation on every traversal. Store an identifier and a scroll offset on the entry; keep heavy data in a cache keyed by that identifier. - Avoid synchronous work in the capture-phase listener. The scroll-stamping listener from Step 4 runs before every navigation; reading
window.scrollYis cheap, but doing layout-thrashing work there will show up directly in your interaction latency. - Prefer
updateCurrentEntryover an extra navigation when you only need to amend state, so you neither grow the history stack nor trigger a redundant transition.
Gotchas & Failure Modes
- Partial browser support is the headline caveat. The Navigation API ships in Chromium (Chrome and Edge 102+) but not in Safari or Firefox at the time of writing, so the History API fallback in Step 5 is mandatory, not optional, for a production site.
- Not every navigation is interceptable. When
event.canInterceptis false — cross-origin destinations, sometraversecases across documents, and downloads — callingintercept()throws. Always guard oncanInterceptfirst and return early otherwise. hashChangenavigations still firenavigate. If you intercept them blindly you break same-page anchor scrolling and in-page fragment links. Checkevent.hashChangeand let the browser handle fragment-only moves.downloadRequestlinks must fall through. An anchor with adownloadattribute produces anavigateevent withcanIntercepttrue; intercept it and you silently break the download. Guard onevent.downloadRequest !== null.- Feature detection must gate the whole subscription.
window.navigationis undefined in unsupported engines, so referencing it unguarded throws at module load. Branch on"navigation" in windowbefore you ever touch the object. navigation.transitionis null outside an interception. Reading transition details from ordinary code returns null; only inspect it inside the handler or anavigatesuccess/navigateerrorlistener.
Go Deeper
- Intercepting Navigation with the Navigation API — a focused build of the
navigatelistener andintercept({ handler }), with every guard you need to own same-document navigations safely. - Migrating from popstate to the Navigation API — a before/after conversion of a pushState-plus-popstate router, with feature detection and a working fallback.
Related
- History API & State Management — the parent overview of URL state, history entries, and navigation on the client.
- pushState & replaceState Usage — the History API methods the Navigation API supersedes and that your fallback path still relies on.
- popstate Event Handling — the back/forward event the single
navigateevent replaces for same-document routing. - Cross-Browser popstate Event Quirks — the engine inconsistencies that motivated a cleaner, unified navigation surface.