# Navigation API

> window.navigation centralises SPA routing: navigate(), the navigate event, intercept(), every exception the HTML Standard defines, and a History API fallback.

`window.navigation` is the HTML Standard's replacement for routing on top of `history.pushState()`: a single `navigate` event fires for every navigation the document starts, and `event.intercept()` converts it into a same-document transition rendered by your handler. The same object exposes the session history as `navigation.entries()` with stable keys, so going back becomes `navigation.traverseTo(key)` instead of counting `history.go()` deltas.

Chrome 102 shipped `Navigation` on desktop and Android, with Edge following at 102 (BCD `api.Navigation`); the `intercept()` method arrived in Chrome 105 and replaced `transitionWhile()`, which existed from Chrome 102 to 108. Firefox 147 and Safari 26.2 on macOS and iOS shipped `Navigation`, `NavigateEvent`, and `intercept()` together. Chrome still accepts `javascript:` URLs in `navigate()`, contrary to the specification ([Chromium bug 439994590](https://crbug.com/439994590)).

## Syntax

```js
navigation.navigate(url)
navigation.navigate(url, options)
navigation.reload()
navigation.reload(options)
navigation.traverseTo(key)
navigation.traverseTo(key, options)
navigation.back()
navigation.back(options)
navigation.forward()
navigation.forward(options)
navigation.updateCurrentEntry(options)

navigation.addEventListener("navigate", (event) => {
  event.intercept(options);
});
```

`navigate()`, `reload()`, `traverseTo()`, `back()`, and `forward()` each return a `NavigationResult`, a plain object with two promises, `committed` and `finished`, that fulfil with the `NavigationHistoryEntry` navigated to. For a same-document navigation handled by `intercept()`, `committed` fulfils once the URL and `navigation.currentEntry` update and `finished` once every handler promise settles. For a cross-document navigation, and for responses with status 204 or 205 or a `Content-Disposition: attachment` header, neither promise ever settles. `intercept()` returns `undefined` and is only valid while the `navigate` event is being dispatched; `updateCurrentEntry()` returns `undefined` synchronously.

## Parameters

`navigate()` takes a `url` string, resolved against the document's base URL, plus an optional `NavigationNavigateOptions` dictionary. `reload()` takes `NavigationReloadOptions`, the three traversal methods take `NavigationOptions`, and `updateCurrentEntry()` takes `NavigationUpdateCurrentEntryOptions` whose single `state` member is required.

| Member | Dictionary | Type | Required | Description |
|---|---|---|---|---|
| `info` | `NavigationOptions` (every method) | `any` | No | Handed to `event.info` on the resulting `navigate` event and discarded afterwards; use it to pass UI hints such as the direction of a swipe. |
| `state` | `NavigationNavigateOptions`, `NavigationReloadOptions`, `NavigationUpdateCurrentEntryOptions` | `any`, structured-serializable | No (Yes for `updateCurrentEntry()`) | Stored on the new entry and read back with `entry.getState()`. Ignored when the navigation ends up cross-document. |
| `history` | `NavigationNavigateOptions` | `"auto"`, `"push"`, or `"replace"` | No, default `"auto"` | `"auto"` pushes a new entry unless the navigation must be a replace (same URL as the current entry, the initial `about:blank` document, or a trivial session history such as a sandboxed iframe); `"push"` in those cases is an error. |

`event.intercept()` takes a `NavigationInterceptOptions` dictionary. Calling it more than once appends handlers; a later `focusReset` or `scroll` value overrides the earlier one, and Chrome logs a console warning when it does.

| Member | Type | Required | Description |
|---|---|---|---|
| `handler` | `() => Promise<undefined>` | No | Runs after the URL commits. Its promise drives `finished`, the `navigatesuccess` or `navigateerror` event on `navigation`, and `navigation.transition`. |
| `precommitHandler` | `(controller) => Promise<undefined>` | No | Runs before the URL changes and may call `controller.redirect(url)`; only allowed when `event.cancelable` is true. |
| `focusReset` | `"after-transition"` or `"manual"` | No | Whether focus moves to the first `autofocus` element, or to `<body>`, once `finished` settles. |
| `scroll` | `"after-transition"` or `"manual"` | No | Whether the browser restores (traversal) or resets (push) scroll position once `finished` settles; with `"manual"`, call `event.scroll()` when your content is ready. |

The `NavigateEvent` itself carries the facts a router needs before deciding: `canIntercept`, `destination` (with `url`, `key`, `index`, `sameDocument`, and `getState()`), `navigationType` (`"push"`, `"replace"`, `"reload"`, or `"traverse"`), `hashChange`, `downloadRequest`, `formData`, `userInitiated`, `signal`, and `info`. `hasUAVisualTransition` was added in Chrome 118 and `sourceElement` in Chrome 135 (BCD `api.NavigateEvent`).

## Exceptions

Method failures surface as an "early error result": both `committed` and `finished` reject with the same `DOMException`. `intercept()` and `scroll()` throw synchronously.

| Exception | Condition |
|---|---|
| `SyntaxError` | `navigate()`: `url` fails to parse against the document's base URL. |
| `NotSupportedError` | `navigate()`: `url` has the `javascript:` scheme, or `history` is `"push"` while the navigation must be a replace. |
| `DataCloneError` | `navigate()`, `reload()`, `updateCurrentEntry()`: `state` is not structured-serializable (a function, a DOM node, a `WeakMap`). |
| `InvalidStateError` | `navigate()` and `reload()`: the document is not fully active or is unloading. `traverseTo()`: no entry has that key. `back()` and `forward()`: `currentEntry.index` is already 0 or the last index. `updateCurrentEntry()`: `currentEntry` is null. `intercept()`: called after dispatch ended, after `preventDefault()`, or with `precommitHandler` on a non-cancelable event. `scroll()`: called before commit or a second time. |
| `SecurityError` | `intercept()`: `canIntercept` is false (the destination is cross-origin, or the browser refuses to turn this navigation into a same-document one), or the event is not trusted. |
| `AbortError` | `committed` and `finished`: the navigation was aborted by a newer navigation, by the user stopping the load, or by `preventDefault()`; `event.signal` fires `abort` at the same moment. |

A rejected `handler()` promise does not raise one of these names; `finished` rejects with the handler's own error and `navigation` fires `navigateerror` with it.

:::observed
Calling `event.intercept()` for a destination on another origin in Chrome throws `SecurityError: A navigation with URL 'https://other.example/' cannot be intercepted by in a window with origin 'https://app.example' and URL 'https://app.example/'.` (the doubled "by in" is in the source). Calling it after an `await` inside the listener throws `InvalidStateError: intercept() may only be called while the navigate event is being dispatched.`, and `navigation.back()` on the first entry rejects both promises with `InvalidStateError: Cannot go back`. The strings come from Chromium's [`navigate_event.cc`](https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/renderer/core/navigation_api/navigate_event.cc) and [`navigation_api.cc`](https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/renderer/core/navigation_api/navigation_api.cc) (chromium.googlesource.com).
:::

## Examples

The examples share one assumption: the app shell is already on the page and contains a `<main id="view">` element that each route fills.

### Rendering routes from a single `navigate` listener

One listener sees link clicks, form submissions, `navigation.navigate()` calls, and back or forward traversals. Return early for anything the router should not own: cross-origin destinations, fragment-only changes, and downloads. The URL has already changed by the time `handler()` runs, so render a placeholder first and swap the fetched content in afterwards.

```js
navigation.addEventListener("navigate", (event) => {
  if (!event.canIntercept || event.hashChange || event.downloadRequest !== null) {
    return;
  }

  const url = new URL(event.destination.url);

  event.intercept({
    async handler() {
      const view = document.querySelector("#view");
      view.setAttribute("aria-busy", "true");
      const response = await fetch(`/fragments${url.pathname}`, { signal: event.signal });
      view.innerHTML = await response.text();
      view.removeAttribute("aria-busy");
    },
  });
});
```

Passing `event.signal` to `fetch()` cancels the request when the user navigates again before it finishes; without it the late response would overwrite the newer route.

### Falling back to the History API when `intercept()` is missing

`window.navigation` alone is not enough to detect a usable implementation: Chrome 102 to 104 expose `navigation` without `intercept()`, so a listener written for `intercept()` would throw a `TypeError` on the first click there. Check the method on the prototype and otherwise wire the classic `click` plus `popstate` pair.

```js
const hasNavigationApi =
  "navigation" in window &&
  typeof NavigateEvent !== "undefined" &&
  typeof NavigateEvent.prototype.intercept === "function";

async function render(pathname) {
  const response = await fetch(`/fragments${pathname}`);
  document.querySelector("#view").innerHTML = await response.text();
}

if (hasNavigationApi) {
  navigation.addEventListener("navigate", (event) => {
    if (!event.canIntercept || event.hashChange || event.downloadRequest !== null) return;
    const { pathname } = new URL(event.destination.url);
    event.intercept({ handler: () => render(pathname) });
  });
} else {
  document.addEventListener("click", (event) => {
    const link = event.target.closest("a[href]");
    if (!link || link.origin !== location.origin || event.button !== 0 || event.metaKey || event.ctrlKey) return;
    event.preventDefault();
    history.pushState(null, "", link.href);
    render(link.pathname);
  });
  window.addEventListener("popstate", () => render(location.pathname));
}
```

The fallback misses what the Navigation API gives for free: form submissions, `location.assign()` calls, and traversals that land on a different document all bypass the `click` listener and reload the page.

### Going back to a known entry with `traverseTo()` and stored state

`navigation.entries()` returns every same-origin entry with a `key` that survives reloads, which lets a wizard return to its first step without knowing how many steps were pushed in between. Store the step in `state` when navigating so the destination can restore its form.

```js
const startKey = navigation.currentEntry.key;

async function goToStep(step) {
  const { finished } = navigation.navigate(`/checkout/${step}`, {
    state: { step, startedAt: Date.now() },
  });
  await finished;
}

function restart() {
  if (!navigation.entries().some((entry) => entry.key === startKey)) {
    navigation.navigate("/checkout/1", { history: "replace" });
    return;
  }
  navigation.traverseTo(startKey);
}

navigation.addEventListener("currententrychange", () => {
  const state = navigation.currentEntry.getState();
  if (state?.step) document.querySelector("#step").textContent = String(state.step);
});
```

If the user has since navigated to another origin and back, `startKey` is no longer in `entries()` and `traverseTo()` would reject with `InvalidStateError`; the `some()` check keeps `restart()` on the replace path instead.

## See also

- [View Transitions](/reference/performance/view-transitions/), the animation layer most Navigation API routers pair with `intercept()`
- [Back/forward cache (bfcache)](/reference/performance/bfcache/), which decides whether a cross-document traversal restores the old document
- [Speculation Rules API](/reference/performance/speculation-rules/), prefetching the routes a `navigate` handler is about to request
- [HTML Standard: the navigation API](https://html.spec.whatwg.org/multipage/nav-history-apis.html#navigation-api) (html.spec.whatwg.org)
- [HTML Standard: navigate() method](https://html.spec.whatwg.org/multipage/nav-history-apis.html#dom-navigation-navigate) (html.spec.whatwg.org)
- [Modern client-side routing: the Navigation API](https://developer.chrome.com/docs/web-platform/navigation-api/) (developer.chrome.com)
- [WICG navigation-api explainer](https://github.com/WICG/navigation-api) (github.com)
- [Chromium bug 439994590: javascript: URLs accepted by navigate()](https://crbug.com/439994590) (crbug.com)