Intercepting Navigation with the Navigation API

After reading this you will be able to register a single navigate listener and call event.intercept({ handler }) to take ownership of same-document navigations — link clicks, form submissions, and back/forward traversals alike — without hijacking the downloads and cross-origin links the browser should keep handling.

← Back to The Navigation API

Prerequisites

Core Concept

Interception is the mechanism by which your application says, “I will handle this navigation in the current document; do not fetch a new one.” The navigate event fires on window.navigation immediately before the browser would perform any same-document navigation, and its event object exposes intercept(). Calling it with a handler — an async function — hands the transition to you: the browser updates the URL, sets navigation.transition, shows its native loading affordance, and waits for your returned promise to settle before considering the navigation complete. A navigatesuccess event fires when it resolves; navigateerror fires if it rejects.

The subtlety is not calling intercept() — it is knowing when not to. The single event fires for navigations you must leave to the browser: cross-origin destinations, file downloads, and fragment-only hash changes. Interception is only legal when event.canIntercept is true, and calling it otherwise throws. So a correct handler is a short sequence of guards that bail out early, followed by a single intercept() call for the navigations that genuinely belong to your router. Get the guards right and the rest of the API rewards you: scroll restoration and focus reset are handled by the platform, and event.signal gives you a ready-made cancellation channel for superseded navigations.

Implementation

The listener below applies every guard in order, resolves the route, and only then intercepts. Each early return leaves the navigation to the browser, which is precisely the correct default for anything your router does not recognise.

// TypeScript 5.4+ — framework-agnostic, no runtime dependencies
type View = { title: string; mount: (params: Record<string, string>, signal: AbortSignal) => Promise<void> };

declare function resolveRoute(pathname: string): { view: View; params: Record<string, string> } | null;

if ("navigation" in window) {
  navigation.addEventListener("navigate", (event: NavigateEvent) => {
    // 1. The browser forbids interception here — cross-origin, and some traversals.
    if (!event.canIntercept) return;

    // 2. A `download` anchor must reach the browser's download machinery untouched.
    if (event.downloadRequest !== null) return;

    // 3. Fragment-only navigations should scroll the page natively, not re-render.
    if (event.hashChange) return;

    // 4. Method-based form posts are out of scope for a read-only view router.
    if (event.formData) return;

    // 5. Only own paths our router actually recognises; unknown paths fall through
    //    to a real navigation (letting the server answer, or produce a 404 document).
    const url = new URL(event.destination.url);
    const matched = resolveRoute(url.pathname);
    if (!matched) return;

    // All guards passed — take ownership of this same-document navigation.
    event.intercept({
      handler: async () => {
        await matched.view.mount(matched.params, event.signal);
        if (event.signal.aborted) return;      // a newer navigation superseded us
        document.title = matched.view.title;
      },
      focusReset: "after-transition",          // let the platform move focus to the new view
      scroll: "after-transition",              // and restore/scroll at the right frame
    });
  });

  navigation.addEventListener("navigateerror", (e) => {
    console.error("Navigation failed:", e.message);   // render an error view here
  });
}

The handler promise is the navigation’s lifetime: while it is pending, navigation.transition is non-null and the browser treats the navigation as in progress. Threading event.signal into view.mount — and checking signal.aborted before you commit any visible change — means a user who clicks three links in quick succession only ever sees the last one paint.

Verification

Interception only behaves correctly through a real browser navigation, so drive it with Playwright rather than dispatching synthetic events, which bypass the API entirely.

// @playwright/test v1.44 — run with --project=chromium
import { test, expect } from "@playwright/test";

test("an in-app link is intercepted, not fully loaded", async ({ page }) => {
  await page.goto("/");
  const before = await page.evaluate(() => performance.getEntriesByType("navigation").length);
  await page.click("a[href='/about']");
  await expect(page).toHaveURL(/\/about$/);
  const after = await page.evaluate(() => performance.getEntriesByType("navigation").length);
  expect(after).toBe(before);                 // no new document load occurred
});

test("a download link is left to the browser", async ({ page }) => {
  await page.goto("/");
  const download = page.waitForEvent("download");
  await page.click("a[download][href='/report.pdf']");
  expect(await (await download).suggestedFilename()).toBe("report.pdf");
});

For a manual check, open DevTools and run navigation.addEventListener("navigate", e => console.log(e.canIntercept, e.hashChange, e.downloadRequest)), then click around: an in-app link logs true false null, a #section link logs hashChange true, and a cross-origin link logs canIntercept false.

Gotchas

  • Calling intercept() when event.canIntercept is false throws an InvalidStateError; the canIntercept guard must come first, before any other logic that might tempt you to intercept early.
  • The navigate event fires for hash changes too, so an unguarded handler that re-renders on every event breaks native in-page anchor scrolling — check event.hashChange and return.
  • Focus and scroll are only managed for you if you keep the default focusReset and scroll values; passing "manual" and then forgetting to do the work yourself is a common source of accessibility regressions where focus is left stranded on the old view.
  • intercept() must be called synchronously inside the event listener; deferring it into a microtask or await before the call means the browser has already committed to a default navigation and the interception is ignored.

FAQ

What is the difference between intercept and preventDefault on a navigate event? There is no preventDefault for same-document routing here — intercept is the replacement. It both stops the browser’s default navigation and defines the transition through the promise its handler returns, whereas simply ignoring the event lets the default navigation proceed.

Why does calling intercept throw an error on some links? Because event.canIntercept was false — the destination is cross-origin, a cross-document traversal, or otherwise off-limits. The browser will not let you claim a navigation it must own, so guard on canIntercept and return early when it is false.

Does the navigate event fire for the browser back and forward buttons? Yes. Back and forward produce a navigate event with navigationType of traverse, and as long as it is a same-document move you can intercept it exactly like a link click, which is what unifies traversal and forward navigation under one handler.

How do I stop a superseded navigation from painting stale content? Thread event.signal into your data fetching and check signal.aborted before committing any visible change. The API aborts the signal automatically when a newer navigation replaces the one in flight, so the older handler abandons quietly.

Can I intercept a form submission with this API? You can — a same-document form navigation carries an event.formData object you can read inside the handler. Whether you should depends on your router; a read-only view router typically guards on formData and lets the browser handle posts.