Navigation API vs History API: What to Replace

The Navigation API replaces the History API for single-page routing. One navigate event covers link clicks, form submissions, back and forward buttons and programmatic calls, where pushState plus popstate only ever covered some of them. It reached Baseline Newly available on 13 January 2026, so a fallback still earns its place.

That date is the whole decision. Everything else about the API has been stable in Chrome since 2022.

With the History API you write a delegated click listener on document, check the target is a same-origin <a> without target="_blank" or a modifier key, call preventDefault(), call history.pushState(), then run your render. Separately you listen for popstate to catch back and forward. Form submissions need their own submit listener. Scroll restoration you either fight or switch off with history.scrollRestoration = "manual".

The Navigation API collapses that into one listener:

navigation.addEventListener("navigate", (event) => {
  if (!event.canIntercept || event.hashChange || event.downloadRequest !== null) {
    return;
  }

  const url = new URL(event.destination.url);
  const route = matchRoute(url.pathname);
  if (!route) return;

  event.intercept({
    async handler() {
      const res = await fetch(route.dataUrl, { signal: event.signal });
      render(route, await res.json());
    },
  });
});

event.destination.url is the target. event.navigationType is one of push, replace, reload or traverse, which is how you tell a fresh link click from a back button without tracking an index yourself. event.signal is an AbortSignal that aborts if the user presses stop or starts another navigation before this one lands, so passing it to fetch gets you cancellation for nothing.

intercept() does more than suppress the page load. The promise your handler returns is what the browser waits on, so the loading UI, the navigation.transition object and scroll restoration all line up with your render rather than with the URL change.

Three cases never reach your handler, and the guard clause above covers all of them. event.canIntercept is false for cross-origin destinations and cross-document traversals; the event still fires so you can log the analytics hit, but calling intercept() there throws a SecurityError. A link with a download attribute sets event.downloadRequest to the filename. A fragment link sets event.hashChange. One more thing worth knowing: the event does not fire on first page load, so your router still needs an explicit render at boot.

Form submissions arrive on the same event

A POST form submission fires navigate with event.formData populated, which the History API never gave you at all.

if (event.formData) {
  event.intercept({
    async handler() {
      const res = await fetch(url, { method: "POST", body: event.formData, signal: event.signal });
      render(await res.text());
    },
  });
  return;
}

Can I use the Navigation API in production yet?

Chrome and Edge shipped it in version 102 in May 2022. Safari 26.2 followed on 12 December 2025, and Firefox 147 on 13 January 2026, which is the date web-features marks it Baseline Newly available. web.dev covered that milestone, and the WebKit team wrote up the Safari side. Widely available is projected for July 2028.

Newly available means the current version of every major browser has it, not that your traffic has it. Check your own analytics for the share of sessions on Safari 26.1 or earlier and Firefox 146 or earlier before you delete anything, then feature detect:

if ("navigation" in window) {
  startNavigationRouter();
} else {
  startHistoryRouter();
}

Two routers is a real cost. If your audience skews to recent browsers you can run the Navigation API path only and let older browsers fall through to full page loads, which is slower but correct, and prerendering with speculation rules takes some of the sting out of that. It works well on content-heavy sites and badly on anything with client state you would rather not throw away.

What happens to scroll position and focus?

Both are handled for you, which is the part hand-written routers usually get wrong. Once your handler’s promise resolves, the browser restores the scroll position for a traversal or scrolls to the fragment for a push, then moves focus to the first element carrying autofocus, or to <body> if there is none.

Take control only when you need to sequence it against something else, such as a view transition:

event.intercept({
  scroll: "manual",
  async handler() {
    const html = await loadPage(url);

    if (!document.startViewTransition) {
      swapMain(html);
      event.scroll();
      return;
    }

    const transition = document.startViewTransition(() => swapMain(html));
    await transition.updateCallbackDone;
    event.scroll();
    await transition.finished;
  },
});

focusReset: "manual" exists too, and you should reach for it far less often. The default behaviour is what stops a screen reader user being left at the top of a document that has silently changed underneath them. If you switch it off, you own that reset. For multi-page sites the same visual effect is available without any of this through our write-up on cross-document view transitions.

How do I read and update history state?

navigation.entries() returns a snapshot array of every same-origin entry in the current history, each with a stable key, a url and its own state. That alone removes a pile of bookkeeping, and navigation.traverseTo(key) jumps straight to one.

const entry = navigation.currentEntry;
navigation.updateCurrentEntry({ state: { ...entry.getState(), filter: "in-stock" } });

navigation.navigate("/products?page=2", { state: { page: 2 }, history: "push" });
await navigation.navigate("/checkout").finished;

updateCurrentEntry() writes state without creating a navigation, which is the honest replacement for the replaceState() habit of stuffing UI state into history. navigate() returns { committed, finished }, two promises: committed resolves when the URL and current entry have changed, finished when your handler has settled. Await the one you actually mean.

navigation.canGoBack and navigation.canGoForward are worth calling out on their own. Enabling or disabling your own back button correctly was close to impossible with the History API.

What still needs the History API

Cancellation is asymmetric. event.preventDefault() stops a push or a replace, but MDN notes it does not yet work for traverse navigations, so you cannot block a back button press to show an unsaved-changes prompt. beforeunload remains the tool there.

precommitHandler, the option that lets you redirect an unauthenticated user before the URL changes rather than after, is limited availability and not part of the Baseline set as of September 2026. Treat it as an enhancement: do the auth redirect inside handler and accept the URL flicker in browsers without it.

The other thing to weigh is that mainstream routers still sit on their own history abstraction, so if you use one, this is not yours to swap. The API pays off for teams that own their routing directly.

My recommendation: if you maintain a hand-rolled router, port it now behind a feature detection branch and delete the click delegation, the popstate listener and the scroll restoration code, which is usually the noisiest third of the file. If you are on a framework router, wait. And if a measurable slice of your traffic sits on browser versions from before the end of 2025, keep the History API path until that number stops mattering.

Whoooop builds and rebuilds client-side routing for React and Astro applications, usually while fixing the interaction and navigation timings that come with it. If you want a second pair of eyes on how your app handles routing, our React development work is where that sits.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch