The Navigation API: Intercepting Browser Navigations
The Navigation API is a new browser API that gives single-page applications a central way to handle all navigations: clicks, back/forward, history mutations, and programmatic navigations. Before it existed, SPAs had to intercept every link click, call history.pushState manually, and listen to popstate and hashchange events separately. That approach missed navigations triggered by the browser’s back and forward buttons in subtle ways.
The Navigation API fixes this by providing one event that catches every navigation in the document.
Checking Support
The Navigation API is relatively new. Check for it before using:
if ("navigation" in window) {
console.log("Navigation API supported");
} else {
console.log("Navigation API not supported");
}
Chrome 102+ and Edge 102+ support it. Firefox and Safari have not yet shipped it fully. Once you confirm the API is available, the entry point is window.navigation, a global object that represents the navigation state of the current document and emits events for every navigation that occurs:
The navigation Global
The API lives on window.navigation, a singleton that persists across navigations within the same document:
const nav = window.navigation;
console.log(nav.type); // "current"
console.log(nav.currentEntry); // NavigationHistoryEntry
console.log(nav.canGoBack); // boolean
console.log(nav.canGoForward); // boolean
The properties on navigation describe the current position in the history stack, but the real power of the API is its event system. Unlike the History API’s fragmented popstate and hashchange events, the Navigation API fires a single navigate event for every navigation, regardless of how it was triggered:
Listening for all navigations
The navigate event fires whenever anything navigates in the document:
window.navigation.addEventListener("navigate", (event) => {
const url = new URL(event.destination.url);
console.log("Navigating to:", url.pathname);
console.log("Navigation type:", event.navigationType);
});
event.navigationType tells you what triggered the navigation:
| Value | Cause |
|---|---|
"push" | New page added to history stack |
"replace" | Current entry replaced |
"traverse" | Back/forward button used |
"reload" | Page reloaded |
The traverse type covers both back and forward. Check event.destination.index against navigation.currentEntry.index to know which direction.
Intercepting a Navigation
You can intercept a navigation and provide a custom response instead of letting the browser handle it naturally. Call event.intercept() with a handler:
window.navigation.addEventListener("navigate", (event) => {
const url = new URL(event.destination.url);
// Only intercept same-origin navigations to articles
if (!url.origin === location.origin) return;
if (!url.pathname.startsWith("/articles/")) return;
// Prevent default navigation and handle it ourselves
event.intercept({
handler() {
// Load article content via fetch and update the page
renderArticle(url.pathname);
}
});
});
The handler runs after the navigation commits, meaning the URL bar has already updated. Inside the handler, fetch content and update the DOM without a full page reload. This is how SPAs get native-like navigation without a framework router. When your handler needs to orchestrate multiple async steps before the page is ready, you can return a promise from it. The intercept call then gives you a transition object you can await:
Waiting for the navigation to complete
The intercept() call returns a NavigationTransition object if the navigation is intercepted and the browser shows no committed indication otherwise:
window.navigation.addEventListener("navigate", (event) => {
if (!shouldIntercept(event)) return;
event.intercept({
handler() {
return fetchAndRender(event.destination.url)
.then(html => updateDOM(html))
.then(() => scrollToContent());
}
});
});
The transition object resolves when your handler finishes, which you can use to sequence post-navigation work. After the navigation commits, the navigation.currentEntry object changes to reflect the new URL and state. To react to that change in your application code, listen for the currententrychange event:
Tracking current entry changes
After a navigation commits, navigation.currentEntry changes. Listen to the currententrychange event to react:
window.navigation.addEventListener("currententrychange", (event) => {
console.log("Navigated to:", event.destination.url);
console.log("Type:", event.navigationType);
// Update your app's state to match
appState.currentPath = event.destination.url;
appState.pageTitle = event.destination.title;
});
This replaces popstate for same-document navigations. It fires on back/forward traversals, replaceState calls, updateCurrentEntry() calls, and intercepted navigations after they complete. Alongside the event, you can inspect the current entry directly through navigation.currentEntry to read its URL, key, and other metadata:
Reading the current entry
navigation.currentEntry is a NavigationHistoryEntry object:
const entry = window.navigation.currentEntry;
console.log(entry.id); // stable string identifier
console.log(entry.url); // full URL
console.log(entry.index); // position in history stack
console.log(entry.key); // used for back/forward
console.log(entry.title); // document title at time of navigation
console.log(entry.sameDocument); // boolean
Each NavigationHistoryEntry has a stable id that survives across the entry’s lifetime. That makes it a reliable cache key: instead of using the URL string for cache lookups, use the entry id so that state, scroll position, and fetched data stay tied to the correct history entry even if the URL changes. Here is how that pattern looks:
The id is stable across the entry’s lifetime. Use it as a cache key:
const cached = cache.get(navigation.currentEntry.id);
if (cached) {
renderFromCache(cached);
} else {
fetchAndRender(navigation.currentEntry.url);
}
Caching by entry ID works well, but sometimes you need to modify the current entry’s state without triggering a full navigation. The updateCurrentEntry() method lets you attach or replace state data directly on the current history entry. Think of it as history.replaceState for the Navigation API, but without firing a navigate event:
Updating state without navigating
navigation.updateCurrentEntry() changes the state object on the current entry without triggering a new navigation:
navigation.updateCurrentEntry({
state: { scrollY: 0, filters: { status: "active" } }
});
This fires currententrychange with navigationType: null. Your app sees it as a state change, not a navigation. Beyond updating state in place, you can also move through the history stack programmatically using the navigation methods that replace the History API’s go(), back(), and forward():
Traversing History
Navigate programmatically:
// Go back
navigation.back();
// Go forward
navigation.forward();
// Go to a specific index
navigation.traverseTo("some-entry-key");
// Navigate to a URL (regular navigation)
navigation.navigate("/articles/new-post");
navigate() fires the navigate event like any other navigation, so your intercept handler runs for it too. By default it does a push. Pass { history: "replace" } to replace instead. When inspecting the history stack to find a specific entry key for traverseTo(), use navigation.entries():
navigation.navigate("/search?q=cats", { history: "replace" });
Reading history entries
Get all entries via navigation.entries(). This method returns an array of NavigationHistoryEntry objects representing the entire session history for the current document. Each entry contains an index, a url, and a stable key string that you can pass to traverseTo() to jump to a specific point in the history stack:
const allEntries = navigation.entries();
console.log("History length:", allEntries.length);
for (const entry of allEntries) {
console.log(entry.index, entry.url, entry.key);
}
The key on each entry is used for traverseTo(). The id is stable across page loads for the same entry. When a handler is running inside event.intercept(), the user might navigate again before the handler finishes. The Navigation API provides an AbortSignal through the handler’s controller parameter so you can cancel in-flight work:
Aborting an intercepted navigation
Your intercept handler receives an AbortSignal you can use to cancel async work if the user navigates away before it finishes:
event.intercept({
handler(controller) {
fetch(event.destination.url, { signal: controller.signal })
.then(r => r.text())
.then(html => render(html))
.catch(err => {
if (err.name !== "AbortError") throw err;
});
}
});
If the user clicks another link or presses back while your handler is running, the signal aborts and you can stop the fetch. Putting all the pieces together, a complete setup combines the navigate event, intercept with abort handling, and the currententrychange event for analytics tracking:
Full Example
// Setup: listen for all navigations
navigation.addEventListener("navigate", (event) => {
const url = new URL(event.destination.url);
// Skip cross-origin and non-article navigations
if (url.origin !== location.origin) return;
if (!url.pathname.startsWith("/articles/")) return;
event.intercept({
handler(controller) {
// Show loading state immediately
showLoadingSpinner();
// Fetch article content
fetch(url.pathname, { signal: controller.signal })
.then(r => r.text())
.then(html => {
document.querySelector("main").innerHTML = html;
document.title = url.pathname.split("/").pop();
})
.catch(err => {
if (err.name !== "AbortError") showError();
});
}
});
});
// Track page views on completion
navigation.addEventListener("currententrychange", (event) => {
analytics.page(event.destination.url);
updateBreadcrumbs(event.destination.url);
});
Comparison with the History API
| Feature | Navigation API | History API |
|---|---|---|
| Central event for all navigations | navigate event | No: intercept each link manually |
| Back/forward detection | traverse type + currententrychange | popstate event |
| Intercept and handle navigation | event.intercept() | pushState + manual DOM update |
| Scroll position control | Built-in via intercept options | Manual |
| Abortable handlers | controller.signal | AbortController with no standard integration |
| Animated transitions | NavigationTransition | None |
The Navigation API replaces the patchwork of pushState, popstate, hashchange, and link-click interception with a single coherent system.
Practical rollout notes
The safest way to introduce the Navigation API is to treat it as an optional upgrade, not a requirement. Start by checking support, then wire the same page transition flow through your existing router or link handler if the browser does not expose window.navigation. That keeps the user experience consistent while you test the new path in browsers that already support it. It also gives you a clear fallback when a user is on an older browser or a narrower embedded web view.
When you do intercept a navigation, keep the handler focused on a small set of paths. A broad interception rule can make debugging painful because every click suddenly shares the same code path. Narrow rules based on origin, pathname, or even a route table help you reason about which requests should stay native and which should become application transitions. That discipline also makes it easier to log, profile, and test the navigation logic without affecting unrelated parts of the site.
Think carefully about state changes that happen during a transition. The URL may update before your content finishes loading, which is usually what you want, but it means your loading UI, analytics hook, and document title updates need to be coordinated. A good pattern is to show a clear loading state early, wait for the handler promise to settle, then update the page title and any route-aware UI together. That sequence keeps the page feeling intentional rather than half-finished.
The API also helps when you need to support back and forward behavior without rebuilding your own history stack. If you keep the current entry in sync with your app state, the browser can do much of the hard work for you. The important habit is to treat navigation.currentEntry as the source of truth for the active document state and to keep your own app state close to it. That makes reloads, restores, and traversals much easier to reason about later.
Finally, test both the happy path and the canceled path. A transition that aborts halfway through should leave the page in a clean state with no stale spinner, no duplicated content, and no lingering fetches. That kind of cleanup is easy to miss during manual testing, but it matters a lot once users start navigating quickly between pages. A few focused checks here will save you from subtle bugs that only show up under real browsing patterns.
If you want a simple review rule, ask whether the page can recover cleanly when the user changes direction mid-flight. If the answer is yes, the interception logic is probably in good shape. If the answer is no, add one more layer of cleanup or a clearer fallback before you ship it.
See Also
- /guides/javascript-service-workers/: service workers can also intercept navigations
- /guides/javascript-pwa-guide/: PWAs use the Navigation API for app-like behavior
- /guides/javascript-streams-api/: combine with streams for progressive article loading