jsguides

The Navigation API

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.

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

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:

ValueCause
"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.

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. You can await it to sequence operations.

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.

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

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

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.

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:

navigation.navigate("/search?q=cats", { history: "replace" });

Reading History Entries

Get all entries via navigation.entries():

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.

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.

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

FeatureNavigation APIHistory API
Central event for all navigationsnavigate eventNo — intercept each link manually
Back/forward detectiontraverse type + currententrychangepopstate event
Intercept and handle navigationevent.intercept()pushState + manual DOM update
Scroll position controlBuilt-in via intercept optionsManual
Abortable handlerscontroller.signalAbortController with no standard integration
Animated transitionsNavigationTransitionNone

The Navigation API replaces the patchwork of pushState, popstate, hashchange, and link-click interception with a single coherent system.

See Also