Navigation API: Modern SPA Navigation Without the History API
AI generated
JS
() =>
JavaScript · Browser APIs · Routing
Navigation API
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.

16 min read Navigation API SPA Router History API Browser Navigation

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);
    },
  });
});

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

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

11. FAQ: Navigation API: The Key Facts at a Glance

1What is the core difference between the History API and the Navigation API?
The History API manipulates a history stack with no knowledge of the actual navigation. The Navigation API models navigation itself as an event with a lifecycle and a central navigate event.
2What is event.intercept() used for?
It tells the browser that a router is taking over a navigation itself instead of loading a new page. The passed handler renders the new view state.
3What do the committed and finished promises from navigate() provide?
committed resolves once the URL is visibly updated, finished only once the intercept() handler has fully completed. This enables precise loading-state feedback.
4Can I block a navigation with the Navigation API?
Yes, via event.preventDefault() in the navigate handler or by throwing inside the intercept() handler, for example after a rejected confirmation dialog.
5Which browsers support the Navigation API?
Currently only Chromium-based browsers like Chrome and Edge. Firefox and Safari don't support it yet, so a fallback to the History API is still needed.
6Does the Navigation API fully replace popstate?
For SPA routers, yes, since the navigate event covers every navigation type including forward/back. popstate still fires, but it's no longer the primary entry point for new routers.
7How do I feature-detect whether the API is available?
With a simple check for 'navigation' in window. If that expression is false, you should fall back to classic History API logic.
8What does navigation.entries() return?
A complete, iterable list of all NavigationHistoryEntry objects in the current session, including URL, index and the state that was passed along.
9Does an existing router need a full rewrite?
No, a thin abstraction layer can switch between the Navigation API and classic History API logic based on feature detection, without changing the public router API.
10Is the Navigation API already worth using in production?
For Chromium-focused contexts like internal tools or PWAs with a known user base, yes; for broad public websites, a fallback for Firefox and Safari is currently still essential.