The History API lets JavaScript change the browser's URL and manage navigation history without triggering a full page reload — the foundational mechanism every client-side router (React Router, Vue Router) is built on.
1The History API | JavaScript Tutorial - In-Depth Guide Part 1
history.pushState() changes the URL shown in the address bar and adds a new entry to the browser's history, without reloading the page.
history.pushState({ page: 'profile' }, '', '/profile');
// URL is now /profile, no reload occurredpushState()
2The History API | JavaScript Tutorial - In-Depth Guide Part 2
history.replaceState() works like pushState() but replaces the current history entry instead of adding a new one — useful for redirects that shouldn't create an extra 'back' step.
history.replaceState(null, '', '/normalized-path');replaceState()
3The History API | JavaScript Tutorial - In-Depth Guide Part 3
The 'popstate' event fires when the user navigates via the browser's back/forward buttons, letting your app respond by rendering the corresponding view.
window.addEventListener('popstate', (event) => {
renderRouteFor(location.pathname, event.state);
});The popstate Event
4The History API | JavaScript Tutorial - In-Depth Guide Part 4
The state object passed to pushState() is retrievable later via event.state in a popstate handler, letting you restore view-specific data without re-fetching it.
history.pushState({ scrollY: window.scrollY }, '', '/list');
// later, on popstate:
window.addEventListener('popstate', (e) => {
window.scrollTo(0, e.state?.scrollY ?? 0);
});Attaching State Data
5The History API | JavaScript Tutorial - In-Depth Guide Part 5
A minimal client-side router combines pushState (for programmatic navigation), an intercepted click handler on links, and popstate (for back/forward) to fully control in-app navigation.
document.addEventListener('click', (e) => {
const link = e.target.closest('a[data-route]');
if (!link) return;
e.preventDefault();
history.pushState({}, '', link.href);
renderRouteFor(location.pathname);
});Building a Minimal Router
6Step-by-Step Breakdown
history.pushState() changes the URL shown in the address bar and adds a new entry to the browser's history, without reloading the page.
Checkpoint: Does calling history.pushState() trigger a full page reload?
- →Yes, the page reloads with the new URL
- →No, only the URL and history entry change
history.replaceState() works like pushState() but replaces the current history entry instead of adding a new one — useful for redirects that shouldn't create an extra 'back' step.
The 'popstate' event fires when the user navigates via the browser's back/forward buttons, letting your app respond by rendering the corresponding view.
Checkpoint: Does the popstate event fire when your own code calls history.pushState()?
- →Yes, every history change fires popstate
- →No, only actual back/forward navigation fires it
The state object passed to pushState() is retrievable later via event.state in a popstate handler, letting you restore view-specific data without re-fetching it.
A minimal client-side router combines pushState (for programmatic navigation), an intercepted click handler on links, and popstate (for back/forward) to fully control in-app navigation.
Next, we'll explore 'Web Storage Best Practices'.
Level Up 🚀
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported.
Fully supported.
Fully supported.
Fully supported.
Accessibility (A11y)
1Move Focus to the New View After a Client-Side Route Change
Because pushState()-based navigation never triggers a full page reload (which would normally reset focus to the top of the document), a custom router must explicitly move focus to the new view's main heading or content area after rendering, so screen reader and keyboard users aren't left with focus stranded on a now-irrelevant element.
SEO Implications
- 1
Client-Side-Only Routing Can Hurt Crawlability Without Server-Side Rendering
Search engine crawlers may not execute JavaScript-driven pushState() navigation the same way a user's browser does; pairing client-side routing with server-side rendering (or static generation) for the initial load of each route is important for reliable indexing.
Best Practices
Intercept Internal Link Clicks to Prevent Full Page Reloads
A Single Page Application should call preventDefault() on internal link clicks and drive navigation through pushState() plus its own rendering logic, reserving real navigation for external links.
Use replaceState() for Redirects and URL Normalization
Adding a full history entry for a redirect or cleanup step creates a confusing double "back" experience; replaceState() avoids polluting history with steps the user never intentionally visited.
Frequent Bugs
Building a custom router that calls pushState() but forgets that popstate won't fire for it, so the view isn't re-rendered until the user separately triggers a browser back/forward action.
Explicitly call your rendering function immediately after every pushState()/replaceState() call, and separately in the popstate handler for back/forward navigation.
Relying on pushState() alone without a popstate listener, so clicking the browser's back button changes the URL but leaves the previous view rendered.
Always register a popstate event listener that re-renders the appropriate view based on the current location whenever back/forward navigation occurs.
Real-World Examples
A Minimal Vanilla-JS Router
A small project wanted client-side navigation between a few views without pulling in a full routing library.
function navigate(path) {
history.pushState({}, '', path);
render(path);
}
window.addEventListener('popstate', () => render(location.pathname));
function render(path) {
document.getElementById('app').innerHTML = routes[path]?.() ?? notFoundView();
}