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);
}
How a navigation flows through the Navigation API A user action reaches the single navigate event, which either passes guards and is intercepted by the handler, or falls through to the browser's default navigation. User action link · form · back navigate event single choke point canIntercept? guards applied intercept({ handler }) app renders the view default navigation browser handles it yes no
Every same-document navigation converges on one event; guards decide whether your handler owns it or the browser does.

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.signal into 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" and focusReset: "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.scrollY is cheap, but doing layout-thrashing work there will show up directly in your interaction latency.
  • Prefer updateCurrentEntry over 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.canIntercept is false — cross-origin destinations, some traverse cases across documents, and downloads — calling intercept() throws. Always guard on canIntercept first and return early otherwise.
  • hashChange navigations still fire navigate. If you intercept them blindly you break same-page anchor scrolling and in-page fragment links. Check event.hashChange and let the browser handle fragment-only moves.
  • downloadRequest links must fall through. An anchor with a download attribute produces a navigate event with canIntercept true; intercept it and you silently break the download. Guard on event.downloadRequest !== null.
  • Feature detection must gate the whole subscription. window.navigation is undefined in unsupported engines, so referencing it unguarded throws at module load. Branch on "navigation" in window before you ever touch the object.
  • navigation.transition is null outside an interception. Reading transition details from ordinary code returns null; only inspect it inside the handler or a navigatesuccess/navigateerror listener.

Go Deeper