Navigation API: The Modern History API Replacement
The modern History API replacement that centralizes SPA routing into a single navigate event
Every SPA you've built has the same hack: intercept link clicks with e.preventDefault(),
call history.pushState(), then update the DOM. The Navigation API replaces this entire
pattern with a single, centralized navigate event that handles links, form submissions,
back/forward buttons, and programmatic navigation—all in one place.
The History API Problem
// ❌ Classic SPA routing: fragmented and incomplete
function updatePage(event) {
event.preventDefault();
window.history.pushState(null, '', event.target.href);
renderRoute(event.target.href);
}
// Intercept all links (but miss dynamically added ones)
document.querySelectorAll('a[href]').forEach(link => {
link.addEventListener('click', updatePage);
});
// Handle back/forward separately
window.addEventListener('popstate', () => {
renderRoute(location.href);
});
// Forms? Image maps? Programmatic navigation? More listeners...
This approach fails in subtle ways: dynamically added links need re-binding, form submissions
require separate handling, and popstate doesn't fire on pushState calls.
The History API was designed for page-by-page navigation, not SPAs.
The Navigation API: One Event
// ✅ Modern SPA routing: centralized and complete
navigation.addEventListener('navigate', (event) => {
if (!event.canIntercept || event.hashChange || event.downloadRequest) {
return; // Let the browser handle these
}
const url = new URL(event.destination.url);
if (url.pathname.startsWith('/app/')) {
event.intercept({
async handler() {
await renderRoute(url.pathname);
}
});
}
});
The navigate event fires for all navigation types: link clicks, form submissions,
navigation.navigate() calls, and even back/forward buttons (with restrictions).
One listener handles your entire routing logic.
Key NavigateEvent Properties
navigation.addEventListener('navigate', (event) => {
// Can you intercept? (false for cross-origin)
event.canIntercept;
// Destination URL
event.destination.url;
// Navigation type: 'push', 'replace', 'reload', 'traverse'
event.navigationType;
// Is this a #hash change only?
event.hashChange;
// Is this a download?
event.downloadRequest;
// Is this a form submission? (FormData or null)
event.formData;
// Abort signal for cancellation
event.signal;
});
The formData property is particularly useful—you can intercept form POSTs and handle
them client-side without a full page reload.
Handling Async Navigation
navigation.addEventListener('navigate', (event) => {
if (!event.canIntercept) return;
const url = new URL(event.destination.url);
event.intercept({
async handler() {
// Show loading state immediately
showLoadingSpinner();
// Fetch data with abort signal
const response = await fetch(`/api${url.pathname}`, {
signal: event.signal
});
const data = await response.json();
renderPage(data);
}
});
});
// Success/failure events
navigation.addEventListener('navigatesuccess', () => {
hideLoadingSpinner();
});
navigation.addEventListener('navigateerror', (event) => {
showError(event.error);
});
The event.signal is an AbortSignal—if the user navigates again
mid-request, pending fetches are automatically cancelled. No more race conditions.
Scroll and Focus Handling
event.intercept({
// Default: scroll to fragment or top after handler resolves
scroll: 'after-transition', // or 'manual'
// Default: focus first autofocus element or body
focusReset: 'after-transition', // or 'manual'
async handler() {
await loadContent();
// Manual scroll if needed
event.scroll();
}
});The browser handles scroll restoration for back/forward navigation automatically—including restoring the exact scroll position, not just scrolling to top.
Programmatic Navigation
// Push new entry (triggers navigate event)
await navigation.navigate('/dashboard');
// Replace current entry
await navigation.navigate('/settings', { history: 'replace' });
// With state
await navigation.navigate('/profile', {
state: { tab: 'settings' }
});
// Back/forward
await navigation.back();
await navigation.forward();
await navigation.traverseTo(entry.key);
// Current entry and history
const current = navigation.currentEntry;
const entries = navigation.entries();
Unlike history.pushState(), navigation.navigate() returns a Promise
that resolves when the navigation completes—including any async work in your intercept handler.
Browser Support
The Navigation API is supported in Chrome 102+ and Edge 102+. Safari and Firefox are still implementing it. For production SPAs, consider feature detection with a History API fallback:
if ('navigation' in window) {
setupNavigationAPI();
} else {
setupHistoryAPIFallback();
}Key Takeaways
- One
navigateevent replaces fragmented click/popstate handling event.signalprovides built-in request cancellation- Scroll/focus restoration is automatic for traversals
navigation.navigate()returns a Promise—await your navigations- Feature-detect and provide a History API fallback for now
Advertisement
Explore these curated resources to deepen your understanding
Official Documentation
Tools & Utilities
Related Insights
Explore related edge cases and patterns
Advertisement