The Navigation API

Client-side routing has been built on a workaround for a decade. You wire a global click listener onto every link, call preventDefault(), push a URL onto the history stack by hand, re-render your view, and then — in a completely separate place — listen for a popstate event so the Back button does something sane. Four disconnected pieces held together with hope. Miss one and your router quietly breaks: the Back button navigates the whole page away, or the URL and the screen disagree.

The Navigation API collapses all of that into one event. Every navigation that stays on your origin — a clicked link, a tapped Back button, a navigation.navigate() call in your code — fires a single navigate event on window.navigation. You handle it in one place, decide whether to take over, and if you do, the browser updates the URL, manages focus, and coordinates scroll for you.

One object, one event

Everything hangs off a single global: window.navigation (usually just navigation). It exposes the current position in history, the full list of entries, methods to move around, and the event that ties it together.

// Is the API even here? Feature-detect before you rely on it.
if ("navigation" in window) {
  navigation.addEventListener("navigate", (event) => {
    // fires for links, back/forward, and navigation.navigate()
    console.log("navigating to", event.destination.url);
  });
}

That one listener is the whole point. Under the old model, a link click and a Back button press reached you through two unrelated mechanisms. Here they’re the same event with the same shape — you tell them apart by reading event.navigationType, not by listening in two places.

click <a href>Back / Forwardform submitnavigation.navigate()navigateone event firesyour onehandlerTell the sources apart with event.navigationType: ‘push’ · ‘replace’ · ‘reload’ · ‘traverse’.
Every same-origin navigation — a clicked link, the Back or Forward button, a form submission, or a navigation.navigate() call in your code — funnels into one navigate event on window.navigation.

Taking over with intercept()

Firing an event is only half of it. The move that makes this a real router is event.intercept(). Call it and the browser cancels its default full-page load, keeps the same document alive, and hands control to you. The URL still changes, the entry still lands in history — but you decide what appears on screen.

navigation.addEventListener("navigate", (event) => {
  // Bail on anything you can't or shouldn't handle (see next section).
  if (!event.canIntercept) return;

  const url = new URL(event.destination.url);

  event.intercept({
    async handler() {
      const html = await fetch(url.pathname).then((r) => r.text());
      document.querySelector("#app").innerHTML = html;
    },
  });
});

The handler is an async function. The browser considers the navigation committed the moment you call intercept() — the address bar updates immediately — and finished only when your handler’s promise resolves. That split matters, and both states are observable, which we’ll get to.

navigateintercept()?noyesbrowser default: destroy document,full network load, fresh pagesame document · URL updates ·your handler renders the view
Without intercept, the browser tears down the document and loads a fresh page. Call intercept and it short-circuits that path: same document, new URL, your handler paints the change.

Not everything can be intercepted

Before you touch intercept(), you have to filter. A navigate event fires for navigations you have no business hijacking — a link to another origin, a file download, a plain in-page hash jump. intercept() throws if you call it when interception isn’t allowed, so guard first.

function shouldHandle(event) {
  // Cross-origin, some traversals, and other cases can't be intercepted.
  if (!event.canIntercept) return false;
  // A download link (<a download>) should download, not route.
  if (event.downloadRequest !== null) return false;
  // A same-page fragment jump (#section) — let the browser scroll.
  if (event.hashChange) return false;
  // A POST form submission you'd rather the browser handle normally.
  if (event.formData) return false;
  return true;
}

The event carries a rich description of what kind of navigation this is. A few properties earn their keep:

  • event.canInterceptfalse for cross-origin destinations and other non-interceptable cases. The one guard you must never skip.
  • event.destination — a NavigationDestination with .url, and .getState() for the state attached to where you’re going.
  • event.navigationType'push', 'replace', 'reload', or 'traverse'. Traverse means Back/Forward.
  • event.userInitiatedtrue if a person clicked or tapped, false if your code triggered it.
  • event.hashChange, event.downloadRequest, event.formData — the filters above.
  • event.signal — an AbortSignal that fires if the navigation is superseded (the user clicks something else mid-load). Pass it to fetch and stale requests cancel themselves.

Reading and moving through history

The Navigation API gives you a clean, readable model of session history — something the old history object flatly refused to do. You can see every entry your document created, know exactly where you are, and jump to any of them.

navigation.currentEntry;   // NavigationHistoryEntry — where you are now
navigation.entries();      // NavigationHistoryEntry[] — the full same-origin list
navigation.canGoBack;      // boolean
navigation.canGoForward;   // boolean

Each NavigationHistoryEntry describes one slot in history:

  • url — the entry’s absolute URL.
  • key — identifies the slot (position). Stable across replacements; this is what you pass to traverseTo().
  • id — identifies the specific entry. A replace gives a new id but keeps the key.
  • index — position in entries(), or -1 if not present.
  • getState() — a structured-clone copy of the state you stored on it.

Moving around

Four methods traverse the list. Each returns a NavigationResult — an object with two promises we’ll unpack in a moment.

navigation.back();            // one step back, if canGoBack
navigation.forward();         // one step forward, if canGoForward
navigation.traverseTo(key);   // jump directly to a specific slot
navigation.reload();          // re-run navigation for the current entry

traverseTo() is the one you couldn’t do before. Save an entry’s key when the user is somewhere meaningful, and later send them straight back to that exact spot, however many pages deep they’ve wandered.

const homeKey = navigation.currentEntry.key;

logoButton.addEventListener("click", () => {
  navigation.traverseTo(homeKey); // fires navigate with navigationType 'traverse'
});

Here is a model of that history list you can drive. Visit a few pages to push new slots, walk the pointer with back() and forward(), then use traverseTo(homeKey) to jump straight back to /home — however deep you’ve wandered:

interactiveWalking the history list with traverseTo()
navigation.entries()/homeindex 0/productsindex 1/products/42index 2/cartindex 3currentEntryback() moves the pointer left · forward() right · traverseTo(key) jumps to a slot
entries() is a flat array; currentEntry points into it. back(), forward(), and traverseTo(key) move the pointer. The navigate event fires on every move with navigationType 'traverse'.

Per-entry state, done right

The old API let you stash a state object with pushState(state, ...), but reading and updating it was awkward and easy to desync. Here, state belongs to an entry and comes back as a fresh clone every time.

You attach state when you navigate:

navigation.navigate("/products/42", {
  state: { scrollY: 0, from: "search" },
  history: "push", // or "replace"; default "auto"
  info: { animate: "slide" }, // one-off data, not persisted
});

Later you read it off whichever entry you like:

const { from } = navigation.currentEntry.getState();

There’s a subtlety worth internalizing: getState() returns a clone. Mutating it changes nothing. To actually update state you go through the API — and if you only want to change state without navigating anywhere, that’s updateCurrentEntry():

// Wrong — mutating a clone does nothing.
navigation.currentEntry.getState().from = "nav"; // no effect

// Right — replace state on the current entry, no navigation.
navigation.updateCurrentEntry({
  state: { ...navigation.currentEntry.getState(), from: "nav" },
});

Try both paths against the same live entry. The left button mutates the clone getState() hands back and the stored state never budges; the right button goes through updateCurrentEntry() and actually replaces it:

interactivegetState() returns a clone — mutating it does nothing

Why this beats pushState + popstate

The gap isn’t cosmetic. The History API forces you to reconstruct, imperfectly, things the browser already knows. Consider what a link click used to require versus now.

History API — four moving partsdocument click listener + preventDefault()history.pushState(state, ‘’, url)manually re-render the viewseparate window.onpopstate handler↑ back/forward arrive here, disconnectedfrom the click path. Easy to desync.Navigation API — one partnavigation.on(‘navigate’)event.intercept({ handler })URL + focus + scroll: handledClicks, back/forward, and codeall arrive at the same handler.
The History API splits routing across a click listener and a separate popstate handler, with manual preventDefault and pushState between them. The Navigation API is one event that already knows the destination, the type, and how to update the URL.

Beyond fewer parts, you get behavior the old approach never handled for you:

  • Focus management. After an interception, the browser moves focus to the interception’s focus target (or the document body) so keyboard and screen-reader users land in the new content. focusReset: 'manual' opts out.
  • Scroll restoration. On a traverse, the browser restores the previous scroll position automatically. You can defer it with scroll: 'manual' and call event.scroll() yourself once the content that needs measuring exists.
  • A real, inspectable history list. entries() and stable keys are things window.history simply never exposed.

The commit / finish lifecycle

Both navigation.navigate() and intercept() expose a two-phase result. The navigation commits (URL and history entry update) well before it finishes (your async handler resolves). Every navigation method returns { committed, finished } — two promises you can await independently.

const result = navigation.navigate("/dashboard");

await result.committed; // URL is now /dashboard, entry exists
await result.finished;  // your intercept handler has fully resolved
navigate()event firescommittedURL + entry updatedfinishedhandler resolvedasync render window — spinner, streamed content, view transition
A navigation splits into two moments. It commits when the URL and history entry update — almost immediately. It finishes when the intercept handler's promise resolves. If the handler throws or the navigation is aborted, finished rejects.

Trigger a navigation and watch the two moments separate. The address bar updates the instant it commits; the “finished” line only turns green once the async handler resolves. Bump DELAY in the code to make the render window longer or shorter:

interactivecommit happens early, finish happens late

There’s also a pair of events for global bookkeeping: navigatesuccess fires when an interception finishes cleanly, navigateerror when the handler throws. Handy for hiding a loading bar or reporting a failed route in one place.

Pairing with View Transitions

The reason intercept() gives you an async handler is so you can do interesting work inside it — including a native animated transition. Wrap your DOM update in document.startViewTransition() and back/forward navigations animate like a real app. (See The View Transitions API for how that snapshotting works.)

navigation.addEventListener("navigate", (event) => {
  if (!shouldHandle(event)) return;

  event.intercept({
    async handler() {
      const page = await loadPage(event.destination.url);
      if (document.startViewTransition) {
        await document.startViewTransition(() => render(page)).finished;
      } else {
        render(page);
      }
    },
  });
});

The gotchas worth knowing

A few more edges:

  • Same-origin, same-frame only. The API sees navigations for the current document’s frame on your origin. Cross-origin destinations still fire the event, but canIntercept is false — you can watch, not take over.
  • You can’t rearrange history. You can read entries() and traverse, but there’s no API to delete or reorder entries. A modal you pushed onto history stays in the list until the user leaves it.
  • Cancelling traversals is limited. preventDefault() cancels most navigations when event.cancelable is true, but browsers restrict cancelling Back/Forward (traverse) navigations. Don’t build a “block the Back button” flow on the assumption it always works — check event.cancelable.
  • url can be null. A cross-document entry served with a restrictive Referrer-Policy reports url as null. Guard before parsing it.

A minimal router, end to end

Putting the pieces together, a functioning client-side router is genuinely small:

async function startRouter(render) {
  if (!("navigation" in window)) return startHistoryFallback(render);

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

    const url = new URL(event.destination.url);
    event.intercept({
      async handler() {
        const view = await resolveRoute(url.pathname, {
          signal: event.signal,
        });
        if (document.startViewTransition) {
          await document.startViewTransition(() => render(view)).finished;
        } else {
          render(view);
        }
      },
    });
  });

  // No navigate event on first load — render once by hand.
  render(await resolveRoute(location.pathname));
}

Here’s that whole idea in miniature: a tiny app where clicking the nav swaps views in place and the Back button just works. The “document loads” counter proves the point — it stays at 1 no matter how much you click, because nothing ever triggers a full page load. The last link is cross-origin, where a real router reads canIntercept === false and steps aside:

interactiveA working mini-router — one handler, no page reloads

The fallback for older visitors

Until every visitor is on a 2026-or-later browser, keep a History-API path. It’s the classic two-piece dance — the very thing the Navigation API retires — but it keeps old browsers working while new ones get the good path.

function startHistoryFallback(render) {
  document.addEventListener("click", (e) => {
    const a = e.target.closest("a");
    if (!a || a.origin !== location.origin || a.hasAttribute("download")) return;
    e.preventDefault();
    history.pushState(null, "", a.href);
    render(resolveRoute(new URL(a.href).pathname));
  });

  window.addEventListener("popstate", () => {
    render(resolveRoute(location.pathname));
  });

  render(resolveRoute(location.pathname));
}
A link click through the Navigation API
1/5
Variables
— nothing yet —
One listener is registered for every same-origin navigation.

Summary

  • window.navigation exposes one navigate event that fires for every same-origin, same-frame navigation: link clicks, Back/Forward, form submits, and navigation.navigate() calls. You route in one place.
  • event.intercept({ handler }) cancels the browser’s page load and hands you an async function to render the new view. The URL, focus, and scroll are managed for you.
  • Always guard with event.canIntercept, and skip downloads (downloadRequest), hash jumps (hashChange), and form posts (formData).
  • navigation.entries(), currentEntry, back(), forward(), and traverseTo(key) give you a real, inspectable history list — something window.history never did. key is the stable slot; id is the specific entry.
  • State lives on entries; getState() returns a clone. Change it via navigate({ state }), reload({ state }), or updateCurrentEntry({ state }). info is a one-shot hint, not persisted.
  • Every navigation returns { committed, finished } promises — commit updates the URL early, finish resolves when your handler completes.
  • Baseline Newly available in early 2026 (Firefox 147, Safari 26.2, plus Chrome/Edge since 2022). Feature-detect with "navigation" in window and keep a history.pushState + popstate fallback for older visitors.