Modern SPA navigation without the History API
Every SPA router has been built around pushState() and popstate for years, even though neither was ever designed for routing, only for manipulating browser history. The Navigation API cleans up these workarounds and delivers real control over every kind of navigation.
Table of Contents
- 1. Why the History API was never the right tool for routing
- 2. The core concept: a single navigate event for everything
- 3. Intercepting navigation with intercept()
- 4. Programmatic navigation with navigation.navigate()
- 5. Access to the complete navigation history
- 6. Deliberately blocking or redirecting navigation
- 7. Direct comparison: History API vs. Navigation API
- 8. Migration strategy for existing routers
- 9. Conclusion: a real navigation primitive instead of workarounds
- 10. Summary
- 11. FAQ
1. Why the History API was never the right tool for routing
The History API with pushState(), replaceState() and the popstate event was introduced in 2010 to let Ajax applications change the address bar without triggering a full page reload. It was never designed as a navigation framework, only as pure history manipulation, which is why SPA routers have had to build crutches for a decade and a half: link clicks get intercepted via addEventListener('click', ...), event.preventDefault() stops the default page change, and pushState() then simulates the URL update.
The problem is that this construction knows nothing about the actual navigation intent. There's no built-in way to tell whether a navigation is currently in progress, whether it was cancelled, or what kind of navigation it was, a click, a back button, a form submission. The popstate event also doesn't fire on pushState() itself, only on forward/back navigation, forcing inconsistent router logic.
2. The core concept: a single navigate event for everything
The Navigation API, available at window.navigation, brings together every kind of navigation, link clicks, form submissions, forward/back buttons and programmatic calls, into a single navigate event. Every navigation, whether triggered by the user or by code, flows through this one point where a router can centrally decide how to handle it.
This eliminates the fragile click-interception logic entirely. Instead of watching every link individually, a router registers a single navigate listener on the global navigation object and gets information such as destination.url, navigationType (push, replace, reload, traverse) and whether the navigation originates from the same document.
// Basic structure of a Navigation API listener
navigation.addEventListener('navigate', (event) => {
const url = new URL(event.destination.url);
console.log('Navigation type:', event.navigationType); // push | replace | reload | traverse
console.log('Target:', url.pathname);
console.log('User initiated:', event.userInitiated);
});
3. Intercepting navigation with intercept()
The real core for SPA routers is event.intercept(). With it, the router tells the browser: 'I'm taking over this navigation myself, don't load a new page'. Inside the intercept() options, a handler is passed as an async function that takes care of actually rendering the new view state, for example loading data and swapping the DOM content.
The decisive advantage over the old approach is that the browser automatically signals a loading state while the intercept() handler runs, for example activating the stop button in the address bar, and correctly handles scroll position restoration without the router having to rebuild it itself. If the handler throws an exception, the navigation cleanly stays in its previous state.
// Minimal SPA router with the Navigation API
navigation.addEventListener('navigate', (event) => {
// Don't intercept external links, downloads, etc.
if (!event.canIntercept || event.hashChange || event.downloadRequest) {
return;
}
const url = new URL(event.destination.url);
event.intercept({
async handler() {
const view = await resolveRoute(url.pathname);
document.getElementById('app').replaceChildren(view);
},
});
});
4. Programmatic navigation with navigation.navigate()
For programmatic navigation, for example after a successful form submission, navigation.navigate(url, options) replaces the combination of history.pushState() plus manual view updates. The method returns an object with two promises, committed and finished: committed resolves as soon as the URL change is visible, finished only once the intercept() handler has fully completed.
This two-stage promise model is a real improvement over pushState(), which works synchronously and gives no feedback whatsoever about the success of the actual view update. A router can now, for instance, show a loading indicator until finished resolves, and react specifically to a rejection of finished, without having to build its own event-bus constructions.
// Programmatic navigation after a form submission
async function handleFormSubmit(orderId) {
const { committed, finished } = navigation.navigate(`/orders/${orderId}`, {
state: { fromCheckout: true },
});
await committed; // URL is now updated
showSkeletonLoader();
await finished; // view is fully rendered
hideSkeletonLoader();
}
5. Access to the complete navigation history
While the History API grants virtually no insight into its own history, aside from the length via history.length, navigation.entries() provides a complete, iterable list of all NavigationHistoryEntry objects in the current session. Each entry has a stable key and id property, a url, and via getState() the state that was passed during navigation.
This enables router patterns that were previously practically impossible, for example detecting whether a back navigation happens within your own app or comes from an external page by comparing currentEntry.index to the index of the previous entry. Per-route scroll restoration can also be controlled far more precisely this way than with the browser's previous heuristics.
// Inspecting the complete navigation history
for (const entry of navigation.entries()) {
console.log(entry.index, entry.url, entry.getState());
}
console.log('Current entry:', navigation.currentEntry.url);
console.log('Can go back:', navigation.canGoBack);
console.log('Can go forward:', navigation.canGoForward);
6. Deliberately blocking or redirecting navigation
A common use case is blocking a navigation, for example when a form has unsaved changes. With the classic History API, this required abusing the awkward beforeunload dialog, which only fires on an actual page leave, not on internal SPA navigation. The Navigation API solves this directly inside the navigate event via event.preventDefault().
Inside the handler, the router can check whether there are unsaved changes and, if needed, show its own confirmation dialog before the navigation actually happens or is discarded. This gives developers full control over the flow without relying on browser-native, barely customizable dialogs.
// Intercepting navigation on unsaved changes
let hasUnsavedChanges = false;
navigation.addEventListener('navigate', (event) => {
if (!hasUnsavedChanges || !event.canIntercept) return;
event.intercept({
async handler() {
const confirmed = await showCustomConfirmDialog(
'Discard unsaved changes?'
);
if (!confirmed) {
throw new Error('Navigation cancelled by user');
}
hasUnsavedChanges = false;
await renderRoute(new URL(event.destination.url));
},
});
});
7. Direct comparison: History API vs. Navigation API
The central conceptual difference is that the History API is history-centric, it manipulates a stack of entries, while the Navigation API is navigation-centric, it models the act of navigating itself as an event with a lifecycle. This shift makes router code considerably more declarative: instead of intercepting clicks and manually synchronizing state, the code reacts to a single, well-defined event.
In practice this means less boilerplate, fewer edge-case bugs around forward/back navigation, and built-in loading states. The downside is browser support: the Navigation API is currently only available in Chromium-based browsers, so production routers still need a History API fallback for Firefox and Safari, usually through a feature-detection pattern with 'navigation' in window.
8. Migration strategy for existing routers
An existing pushState()-based router doesn't need a complete rewrite to benefit from the Navigation API. A sensible approach is an abstraction layer that internally checks whether 'navigation' in window is available, and depending on the result uses either the navigate event listener with intercept() or the classic click-interception logic with pushState(), while the public router API for the rest of the application stays unchanged.
For new projects that deliberately target only modern Chromium browsers for extended features, for example internal tools or PWA contexts with a known user base, the Navigation API can already be the sole foundation today, with a simple redirect notice for unsupported browsers.
9. Conclusion: a real navigation primitive instead of workarounds
The Navigation API isn't a minor extension of the History API, but a fundamentally different, far more fitting abstraction for SPA routing. With intercept(), the two-stage navigate() promise model, and full entries() access, it solves problems SPA routers previously had to build their own error-prone workarounds for.
As long as browser support isn't complete, a fallback remains necessary, but for Chromium-focused projects or progressive enhancement, switching over already pays off today. The table below compares both approaches directly.
| Aspect | History API | Navigation API | Advantage |
|---|---|---|---|
| Intercepting navigation | Click listener + preventDefault() | event.intercept() in the navigate event | Centralized instead of spread across every link |
| Programmatic navigation | history.pushState() + manual rendering | navigation.navigate() with committed/finished | Built-in success/failure feedback |
| Inspecting history | Only history.length | navigation.entries() with full access | Precise scroll/state restoration |
| Browser support | All browsers | Chromium-based | History API still needed as fallback |
Mironsoft
Modern browser APIs, performance, and maintainable JavaScript
JavaScript that holds up in the real browser, not just in the tutorial?
We review existing frontend code for outdated patterns, unnecessary dependencies, and performance traps, then replace them with modern, native browser APIs that mean less bundle weight and less maintenance burden.
Code Review
Systematically finding outdated patterns, unnecessary dependencies, and memory leaks.
Performance Optimization
Improving bundle size, load time, and runtime performance with modern APIs.
Modernization
Deliberately introducing native browser APIs instead of heavy libraries.
10. Summary
Navigation API: The Key Facts at a Glance
Central event
The navigate event unifies every navigation type in a single handler
intercept()
Takes over rendering the new view state with a built-in loading state
navigate()
Returns committed and finished promises instead of synchronous pushState()
entries()
Full, iterable access to the complete navigation history