History Api And Spa Routing
A single-page application changes what the user sees without loading a new HTML file. The History API makes the URL reflect that change. Here is how the pieces connect.
The first time I tried to build navigation into a vanilla JS app, I just used links. The browser navigated. The app reloaded. Everything in memory was gone. The History API is the piece I was missing -- a way to change the URL without asking the server for a new page.
The Essentials
- SPA routing: One HTML file. JavaScript changes the DOM contents based on the URL. The server never serves a different file.
- Remove/inject vs hide/show: Two strategies for switching views. Remove/inject is more memory-efficient for large apps; hide/show is faster for small ones.
history.pushState(state, unused, url): Changes the browser's URL bar without triggering a server request. The second argument is specified as unused in the current spec.popstateevent on window: Fires when the user navigates with the Back or Forward buttons. Does not fire for external links or manual URL changes.- Server configuration matters: For SPAs to support deep links and page refreshes, the server must forward all routes to
index.html.
Two Strategies for Switching Views
When the user clicks from the menu to the cart, something in the DOM needs to change. There are two basic approaches.
Remove and inject: Each page is a DOM element. When navigating, remove the current page element from the DOM and insert the new one. Only one page element exists in memory at a time per route change.
Hide and show: All page elements are always in the DOM. Navigation toggles the hidden attribute. Simpler logic, but every page is in memory simultaneously.
Neither approach is universally better. Hide/show is fine for apps with a small number of views where each view's content is loaded once. Remove/inject is cleaner for larger apps where each view renders based on data and should be rebuilt on each visit.
For the Coffee Masters project, the remove/inject approach gives a cleaner demonstration of how routing works.
History.pushState
The History API gives JavaScript the ability to change the URL in the browser's address bar without triggering a navigation to the server:
history.pushState({ route: '/order' }, '', '/order');Three arguments:
- State object: Any serializable data you want associated with this history entry. You can read it back later when the user navigates to this point in history.
- Second argument (unused): The spec originally defined this as a title string, but browsers never implemented it consistently. The spec now marks it as unused. Pass an empty string or null.
- URL: The path to display in the address bar. Can be any path under the same origin. The server does not need to serve this path.
After calling this, the URL bar shows /order. The user can bookmark it. But the server has no /order file. If they reload the page, the server returns a 404.
The popstate Event
When the user presses Back or Forward, the popstate event fires on window:
window.addEventListener('popstate', (event) => {
const route = event.state?.route ?? '/';
// Re-render based on the new route
router.go(route, false); // false = don't push a new history entry
});event.state contains the state object you passed to pushState when this history entry was created. Reading the route back from state is more reliable than parsing window.location.pathname, because the state follows the history entry wherever it goes.
popstate does not fire when:
- The user types a new URL in the address bar
- The user clicks a link to a different domain
- Your JavaScript calls
pushState(only Back/Forward triggers it)
This means your router needs to handle two cases: programmatic navigation (via a click your code intercepts) and user-initiated history navigation (via Back/Forward, handled by popstate).
The Server Configuration Problem
If the user loads the app at https://example.com/order directly (via bookmark or link from outside the app), the server receives a request for /order. If that path does not exist on the server, the response is 404.
The standard solution for SPAs: configure the server to serve index.html for all paths (or all paths that do not match a static file). The request arrives, the server returns index.html, the JavaScript loads, and the router reads the URL and renders the correct view.
This is not a vanilla JS problem specifically. React Router, Vue Router, and Angular Router all have the same requirement. The solution is the same regardless of which tool you use for routing.
Further Reading and Watching
- MDN: History API - Full reference for
pushState,replaceState, andpopstate. - MDN: Window: popstate event - Reference for when
popstatefires and whatevent.statecontains.
Video:
- History API - JavaScript Tutorial by dcode. A practical walkthrough of
pushStateandpopstatefor building client-side navigation.
Keep reading