Topic: Web platform

Navigation API: A Router Primitive, Not an SPA Mandate

The Navigation API centralises SPA navigation, but only intercept same-origin GETs your app can safely finish. Leave forms, downloads, hash links, external destinations, and unsupported browsers to ordinary navigation.

Animated meme (expand/collapse)
The Navigation API is not permission to intercept every link. Let the browser handle navigations that the app cannot fully own. · Source: GIPHY

The Navigation API reached Baseline in 2026. For anyone writing a router, its appeal is immediate: instead of stitching together link clicks, popstate, and pushState(), a single navigate event can observe navigation.

The easy part to miss is this: seeing a navigation does not mean that an application should take it over.

It is not a switch that turns an MPA into an SPA, and it is not a new reason to call preventDefault() on every <a>. A server returning a complete document, plus the browser managing history, scrolling, focus, and downloads, is already a dependable default path. The Navigation API is more useful as a router primitive: when I choose client-side navigation, I can state its boundary in one place.

Keep the no-op path first

A site made of articles, forms, and ordinary links does not need a client router merely because this API is Baseline. MPAs still have strong first-load, SSR, recovery, and caching behaviour. If a measured full reload is not the problem, adding DOM swaps, loading states, and error handling for a new API is unnecessary work.

Even when a partial SPA is warranted, the first question is not “which links should I intercept?” It is “which navigations can I own from start to finish?” This deliberately conservative guard only handles interceptable, same-origin, non-hash, non-download, non-form navigation.

function shouldUseClientRouter(event) {
  const url = new URL(event.destination.url);

  return (
    event.canIntercept &&
    url.origin === location.origin &&
    !event.hashChange &&
    !event.downloadRequest &&
    !event.formData
  );
}

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

    const url = new URL(event.destination.url);
    event.intercept({
      handler: () => loadRoute(url, event.signal),
    });
  });
}

canIntercept makes an important distinction for cross-origin and otherwise non-interceptable navigation; the remaining checks are product decisions. Letting the browser jump to #comments is usually more reliable, a download is not a page route, and a POST with formData often carries validation, authorisation, CSRF, redirects, and server-error semantics. Until a router implements those semantics, ordinary navigation is both less code and fewer bugs.

The URL changes first; the UI must not pretend otherwise

After intercept() is called, the URL can commit before the handler runs. While async data is still on its way, a person can see a new URL with old content, leaving relative links, sharing, and Back controls in an awkward state.

The first visible action of a route handler should therefore be boring: render a placeholder for the new route, then fetch. Do not wait for the fetch to make the first response to a click.

async function loadRoute(url, signal) {
  renderArticlePlaceholder(url.pathname);

  try {
    const response = await fetch(`/api/page?path=${encodeURIComponent(url.pathname)}`, {
      signal,
    });

    if (!response.ok) throw new Error(`Route request failed: ${response.status}`);
    renderArticle(await response.json());
  } catch (error) {
    if (error.name === "AbortError") return;
    throw error;
  }
}

When someone chooses another link, the previous NavigateEvent.signal aborts. Passing it into fetch prevents an old request arriving late and replacing the newer screen. An AbortError is expected cancellation, not a user-facing load failure. Actual failures can reach one navigateerror handler that offers a retry or a full-document navigation.

Animated meme (expand/collapse)
When a new navigation replaces an old one, stop stale requests so the UI follows the person's latest destination. · Source: GIPHY

Do not take work away from the browser without a reason

After an intercept() handler completes, the browser normally manages scroll and focus for the new route: a new navigation goes to the top or a fragment, Back and Forward attempt to restore position, and focus moves to an autofocus element or the body. That default is not missing control; it is part of accessible, history-aware navigation.

Set scroll: "manual" or focusReset: "manual" only when a router has different, testable rules. An inline filter that does not replace main content, or an app with deliberate scroll restoration, may need it. “It feels faster” is not a reason. Manual mode means accepting responsibility for keyboard focus, fragments, and return position.

Completion and failure can remain central instead of being copied into every link callback:

navigation.addEventListener("navigatesuccess", () => {
  hideRouteProgress();
});

navigation.addEventListener("navigateerror", () => {
  hideRouteProgress();
  showRouteError();
});

This is where the API earns its place: a navigation becomes a flow with a start, cancellation, success, and failure, rather than a collection of unrelated click handlers.

A fallback is not a compatibility footnote

Feature detection should wrap only the enhancement. Every link still points to a URL that a server can fully respond to. In an older or constrained browser without window.navigation, no polyfill is needed to be barely correct: the script does nothing and ordinary navigation still works.

That boundary also makes a rollout manageable. Start with one same-origin, read-only view without complex forms; observe cancellation rate, route errors, Back position, and keyboard focus; then decide whether to expand. The Navigation API makes router boundaries clearer. It does not remove them.

What I learned

  • I treat the Navigation API as a central primitive for SPA navigation, not as a reason to add a client router to every MPA.
  • I name navigation that cannot be safely intercepted first; ordinary document navigation is both a fallback and the correct baseline most of the time.
  • Once I intercept, placeholder UI, cancellation, scroll, focus, and error handling are part of the contract, not optional speed polish.

External references