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 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)
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.