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.hrefduring 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. - sameDocument —
truewhen intercept is allowed (same origin + document). - getState() — structured-clone state from
navigate()orupdateCurrentEntry().
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:
- All
/catalog/*same-origin navigations intercept. - Handler wraps DOM swap in
document.startViewTransition()(see our View Transitions guide). - Prerender rules (Speculation Rules, moderate eagerness) warm sibling SKUs;
activation reuses prerendered DOM when
event.destination.urlmatches. - Filter state stored in
getState()on push; chip toggles useupdateCurrentEntrywhen only sort order changes. 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 links —
canInterceptis false; let the browser navigate normally. - Async intercept without error handling — a rejected
handlerleaves navigation hung; use try/finally and fall back tolocation.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
updateCurrentEntryorreplacehistory. - 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
navigatelistener; route bydestination.urlpathname. - 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
- View Transitions API explained — morph DOM swaps inside intercept handlers
- Speculation Rules API explained — prerender targets before navigate fires
- Core Web Vitals explained — INP and LCP impact of navigation patterns
- Performance Observer API explained — measure intercept wins in production RUM