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.
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.
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.canIntercept—falsefor cross-origin destinations and other non-interceptable cases. The one guard you must never skip.event.destination— aNavigationDestinationwith.url, and.getState()for the state attached to where you’re going.event.navigationType—'push','replace','reload', or'traverse'. Traverse means Back/Forward.event.userInitiated—trueif a person clicked or tapped,falseif your code triggered it.event.hashChange,event.downloadRequest,event.formData— the filters above.event.signal— anAbortSignalthat fires if the navigation is superseded (the user clicks something else mid-load). Pass it tofetchand 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 totraverseTo().id— identifies the specific entry. A replace gives a newidbut keeps thekey.index— position inentries(), or-1if 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:
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:
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.
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 callevent.scroll()yourself once the content that needs measuring exists. - A real, inspectable history list.
entries()and stablekeys are thingswindow.historysimply 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
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:
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
canInterceptisfalse— 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 whenevent.cancelableistrue, but browsers restrict cancelling Back/Forward (traverse) navigations. Don’t build a “block the Back button” flow on the assumption it always works — checkevent.cancelable. urlcan benull. A cross-document entry served with a restrictiveReferrer-Policyreportsurlasnull. 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:
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));
}
Summary
window.navigationexposes onenavigateevent that fires for every same-origin, same-frame navigation: link clicks, Back/Forward, form submits, andnavigation.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(), andtraverseTo(key)give you a real, inspectable history list — somethingwindow.historynever did.keyis the stable slot;idis the specific entry.- State lives on entries;
getState()returns a clone. Change it vianavigate({ state }),reload({ state }), orupdateCurrentEntry({ state }).infois 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 windowand keep ahistory.pushState+popstatefallback for older visitors.