10LOC
Chrome APIsadvanced

The Navigation API for SPA routing without a router library

Published November 20, 2026

// TypeScript's DOM lib has neither URLPattern nor the Navigation API yet --
// narrow casts for just the shapes this snippet actually uses.
type UrlMatch = { pathname: { groups: Record<string, string | undefined> } };
type UrlPatternLike = { test: (i: { pathname: string }) => boolean; exec: (i: { pathname: string }) => UrlMatch | null };
const URLPattern = (globalThis as any).URLPattern as new (init: { pathname: string }) => UrlPatternLike;

type NavigateEvent = Event & {
  canIntercept: boolean;
  hashChange: boolean;
  downloadRequest: string | null;
  destination: { url: string };
  intercept: (options: { handler: () => void }) => void;
};
const navigation = (globalThis as any).navigation as {
  addEventListener: (type: "navigate", listener: (event: NavigateEvent) => void) => void;
};

type Params = Record<string, string | undefined>;

const setContent = (text: string) => {
  document.querySelector("main")!.textContent = text;
};

const routes: { pattern: UrlPatternLike; render: (params: Params) => void }[] = [
  { pattern: new URLPattern({ pathname: "/users/:id" }), render: (p) => setContent(`User ${p.id}`) },
  { pattern: new URLPattern({ pathname: "/posts/:slug" }), render: (p) => setContent(`Post ${p.slug}`) },
];

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

  const { pathname } = new URL(event.destination.url);
  const match = routes.find((route) => route.pattern.test({ pathname }));

  event.intercept({
    handler: () => {
      if (!match) return setContent("Not found");
      match.render(match.pattern.exec({ pathname })!.pathname.groups);
    },
  });
});

What

The navigate event on the global navigation object fires for every navigation — link clicks, history.pushState, back/forward, form submissions — in one place, and event.intercept() lets you take over rendering without a full page load.

Why it matters

Before this API, SPA routing was built on the History API plus a pile of workarounds: a global click listener that checks event.target.closest("a"), event.preventDefault(), manual pushState, and a separate popstate listener for back/forward that has to reimplement the same routing logic because pushState doesn't fire it. Every router library (React Router, Vue Router, etc.) is largely this workaround, hidden behind an API.

navigation.addEventListener("navigate", ...) unifies all of it. One event, one handler, and event.destination tells you exactly where the browser is trying to go regardless of why — link click, programmatic navigation.navigate(), or history traversal all land in the same place.

How it works

  • event.canIntercept is false for navigations you're not allowed to hijack — cross-origin navigations, for instance. Check it before calling intercept().
  • event.hashChange is true for a same-document hash-only change (#section); the snippet skips those since a hash jump usually just wants default scroll behavior, not a route re-render.
  • event.downloadRequest is non-null when the navigation is actually a download (an <a download> link) — also worth skipping.
  • new URL(event.destination.url).pathname gives you the path to match against your route table, the same string you'd otherwise get from location.pathname after the fact.
  • event.intercept({ handler }) is the actual takeover: the browser stays on the current document, runs handler (which can be async), and updates the URL bar and history entry to match the destination once handler resolves.

Gotchas

  • intercept() only works for same-origin navigations. Cross-origin links always do a real navigation — canIntercept will be false for them, so the guard clause in the snippet is what keeps external links working normally.
  • The Navigation API doesn't replace history.pushState-based code by itself; if you have existing manual pushState calls, they'll still fire navigate events, so migrating means removing the old click/popstate handlers, not layering this on top of them.
  • TypeScript's ambient DOM types for navigation and NavigateEvent are recent additions and may not be present in every toolchain — if navigation shows up as undefined in your editor, that's a types-lib gap, not a runtime one, on browsers that support the API.
  • This is a same-document routing primitive; it doesn't intercept cross-document (full page load) navigations for you to prerender or transform — that's what the Speculation Rules API is for.

Browser support: reached Baseline "newly available" status in January 2026, with Chrome, Edge, Firefox, and Safari all shipping it — but Firefox and Safari only added support recently, and Safari is missing some advanced pieces (like the precommitHandler option on intercept()). Feature-detect with "navigation" in window and keep a pushState-based fallback if you need to support older releases.

Related

  • The History API (pushState/popstate) — the older, lower-level primitive this builds on; still there for the browsers that don't support navigation yet.
  • The Speculation Rules API — operates one level up, at the cross-document navigation the browser hasn't started yet.