Guide

Navigation API explained

Harbor Commerce's product catalog was a hybrid: server-rendered HTML for SEO, but filter chips and sort dropdowns swapped the grid with client-side fetch() and history.pushState(). Back-button behavior was wrong half the time — users landed on a URL whose DOM did not match, scroll position jumped unpredictably, and View Transitions could not hook cross-document navigations because the team never let the browser navigate. The Navigation API fixes that gap. It exposes the session history as typed NavigationHistoryEntry objects, fires a cancellable navigate event for every navigation (link clicks, location.assign, back/forward), and gives you navigation.intercept() to run same-document updates while the URL, history stack, and accessibility tree stay coherent. Pair it with Speculation Rules for prerendered targets and Performance Observer to validate wins. This guide covers entries and keys, programmatic navigation.navigate(), intercept handlers, scroll and focus restoration, a Harbor Commerce catalog refactor, a technique decision table, pitfalls, and a production checklist.

Why the History API was not enough

history.pushState(state, unused, url) mutates the address bar and stores opaque state, but it does not fire events on link clicks or history.back() in a unified way. Frameworks paper over this with custom routers, but multi-page apps that want some client-side transitions — filter changes that should feel instant yet remain shareable URLs — end up with fragile popstate listeners that race full reloads.

What Navigation API adds

The global navigation object (on window) is the single entry point. navigation.currentEntry describes where you are now; navigation.entries() returns the in-memory session stack. Every navigation — same-document or cross-document, user-initiated or script-driven — dispatches navigate before committing. Handlers can call event.intercept() to replace the default load with your own async work while preserving back/forward semantics. That is the missing primitive between classic MPAs and full SPA frameworks.

NavigationHistoryEntry: URL, key, id, and state

Each stack slot is a NavigationHistoryEntry with:

  • url — fully resolved URL string (may differ from location.href during intercept).
  • key — stable per slot; survives replace; use as React/Vue route cache key.
  • id — unique per entry instance; changes on reload.
  • index — position in entries(); 0 is first session entry.
  • sameDocumenttrue when intercept is allowed (same origin + document).
  • getState() — structured-clone state from navigate() or updateCurrentEntry().

navigation.updateCurrentEntry({ state }) mutates metadata (selected filter IDs, scroll anchors) without pushing a new history slot — ideal for ephemeral UI that should not fork the back stack.

The navigate event and intercept()

Register once: navigation.addEventListener("navigate", handler). The event carries destination.url, downloadRequest, formData, info (from navigate(url, { info })), and flags hashChange, historyAction (push, replace, traverse), and userInitiated.

Intercept pattern

navigation.addEventListener("navigate", (event) => {
  const url = new URL(event.destination.url);
  if (event.downloadRequest) return;
  if (!event.canIntercept) return;
  if (!url.pathname.startsWith("/catalog/")) return;

  event.intercept({
    async handler() {
      const html = await fetch(url).then(r => r.text());
      const doc = new DOMParser().parseFromString(html, "text/html");
      document.querySelector("#grid").replaceWith(
        doc.querySelector("#grid")
      );
      document.title = doc.title;
    },
    scroll: "after-transition",
    focus: "after-transition"
  });
});

event.intercept() must be called synchronously during the event. The handler runs instead of a full document unload. Options scroll and focus accept after-transition when paired with View Transitions inside the handler. If you do not intercept, the browser performs a normal navigation (which Speculation Rules may have already prerendered).

traverseTo, reload, and back

navigation.traverseTo(key) moves to an existing entry; navigation.back() / forward() are shorthand. navigation.reload() refreshes the current entry. Programmatic navigation uses navigation.navigate(url, { history: "push" | "replace", state, info }) — it flows through the same navigate event, so one listener covers clicks and JS.

Harbor Commerce catalog refactor

Before: filter chips called pushState then fetched JSON; product links did full reloads; back from a detail page sometimes showed an empty grid because popstate did not re-fetch. After:

  1. All /catalog/* same-origin navigations intercept.
  2. Handler wraps DOM swap in document.startViewTransition() (see our View Transitions guide).
  3. Prerender rules (Speculation Rules, moderate eagerness) warm sibling SKUs; activation reuses prerendered DOM when event.destination.url matches.
  4. Filter state stored in getState() on push; chip toggles use updateCurrentEntry when only sort order changes.
  5. scroll: "after-transition" restores list position from state on back navigation.

Field INP on filter-to-grid updates dropped from 280 ms to 95 ms (median mobile). Full product navigations that miss prerender still beat the old path because intercept parses HTML once instead of reloading CSS/JS bundles.

Technique decision table

Approach Best for History / URL Trade-offs
Navigation API intercept MPA islands, partial DOM swaps, View Transitions on real URLs Native stack, unified navigate event Chrome/Edge 102+; intercept same-document only
history.pushState + popstate Legacy browsers, tiny state tweaks Manual sync; no click hook Back-button bugs; races with full loads
Framework router (React Router, etc.) Full SPA, client-only routes Framework history shim Heavier JS; SEO needs extra work
Speculation Rules prerender High-confidence next page, no intercept Full navigation on activation Analytics/state pitfalls; not a router
Turbo / HTMX / unpoly Progressive enhancement over HTML Varies by library Dependency; may adopt Navigation API internally
Full page reload Auth boundaries, POST-redirect-GET, simple sites Always correct Slowest; discards JS heap

Common pitfalls

  • Intercepting cross-origin linkscanIntercept is false; let the browser navigate normally.
  • Async intercept without error handling — a rejected handler leaves navigation hung; use try/finally and fall back to location.assign.
  • Intercepting form POST navigations — check formData; many flows should not be replaced with fetch HTML.
  • Stale prerender + intercept — if Speculation Rules prerendered a page, ensure your handler does not double-fetch unless cache headers require it.
  • Breaking focus management — after DOM swap, move focus to a sensible heading; use focus: "after-transition" with View Transitions.
  • Forking history on every keystroke — debounce search filters; use updateCurrentEntry or replace history.
  • Assuming entries() is persistent — session stack clears on tab close; do not treat it as long-term storage.
  • Missing feature detection — guard with if ("navigation" in window) and keep no-JS / unsupported fallbacks.

Production checklist

  • Audit which path prefixes should intercept vs full reload (auth, checkout POST).
  • Single navigate listener; route by destination.url pathname.
  • Store restorable UI state in navigate() state or entry metadata.
  • Wrap DOM updates in View Transitions when same-document; set scroll/focus options.
  • Pair high-confidence links with Speculation Rules prerender where appropriate.
  • Test back, forward, reload, and middle-click open-in-new-tab (should not intercept).
  • Verify analytics fire once per committed navigation, not per intercept attempt.
  • Measure INP and LCP with Performance Observer before/after on real devices.
  • Provide non-intercept fallback for browsers without Navigation API.
  • Document which routes remain full MPAs for cache and CDN simplicity.

Key takeaways

  • Navigation API unifies link clicks, programmatic navigations, and traverse in one navigate event.
  • intercept() enables same-document DOM updates while preserving correct history, scroll, and focus semantics.
  • NavigationHistoryEntry key/id/state replace ad-hoc popstate bookkeeping for hybrid MPAs.
  • Harbor Commerce cut catalog INP from 280ms to 95ms by intercepting /catalog/* with View Transitions.
  • Layer Speculation Rules for prerender and Performance Observer for RUM — intercept is not a replacement for either.

Related reading