Building The Router
A router is a service that maps URLs to views. Here is how to build one from scratch using pushState, link interception, and a go/init API.
Knowing pushState exists is one thing. Wiring it into a service the rest of the app can call is another. I needed two things: a go method that updates both the URL and the view, and a way to intercept link clicks before the browser handles them as full page navigations.
The Essentials
- Router as a service: The router belongs in
services/Router.jsalongside the API and Store services. It is an object with agomethod and aninitmethod. router.go(route): CallspushStateto update the URL and triggers the view change. The rest of the app calls this for programmatic navigation.router.init(): Reads the current URL on page load and renders the correct view for that URL. Handles deep links and refreshes.- Link interception: Attaching a click handler to each
<a>element, callingevent.preventDefault(), and then callingrouter.go()instead. This converts full page navigation into client-side navigation. event.targetvs a captured variable: When setting up click handlers in a loop,event.targetrefers to the element that was actually clicked. A variable from the outer loop is a closure and might point to the wrong element by the time the handler fires.
The Router Service
The router is responsible for two things: navigating to a route and initializing from the current URL. A minimal version:
// services/Router.js
import { renderRoute } from '../router.js';
const Router = {
go(route, addToHistory = true) {
if (addToHistory) {
history.pushState({ route }, '', route);
}
renderRoute(route);
},
init() {
const route = window.location.pathname;
this.go(route, false);
}
};
export default Router;go takes a route string and an optional flag. When addToHistory is true (the default), it calls pushState to record the navigation. When false, it renders without adding to history - used during initialization and for Back/Forward navigation.
init reads the current URL from window.location.pathname and renders accordingly. Calling go with addToHistory = false avoids adding the initial URL to history again on top of itself.
Intercepting Link Clicks
Navigation links in a vanilla SPA are plain <a> elements. Without interception, clicking one triggers a server request. The fix is to attach a click handler that calls event.preventDefault() and hands the destination to the router:
document.querySelectorAll('a[data-navlink]').forEach(a => {
a.addEventListener('click', event => {
event.preventDefault();
Router.go(event.target.getAttribute('href'));
});
});event.preventDefault() stops the browser's default behavior - the navigation request. Then Router.go takes over.
The data-navlink attribute marks which links are client-side nav links. Not every <a> on the page should be intercepted. External links, mailto links, and download links should be left alone.
event.target vs the Loop Variable
When attaching handlers inside a forEach, there is a subtle choice: read the href from event.target inside the handler, or capture the loop variable a in a closure.
// Using event.target - reads the clicked element at event time
a.addEventListener('click', event => {
Router.go(event.target.getAttribute('href'));
});
// Using the closure variable - also works, but the `a` reference
// is captured per iteration since forEach creates a new scope each time
a.addEventListener('click', () => {
Router.go(a.getAttribute('href'));
});Both work in a forEach because the callback creates a new scope for each iteration, so a is correctly captured per element. The event.target version is more explicit: it says "read from the element that was actually clicked," which makes the behavior clear without needing to reason about closures.
In older patterns using for with var, this distinction mattered more - var does not create block scope, so the loop variable would be shared across all handlers. forEach eliminates that specific issue.
Wiring It Up in app.js
import Router from './services/Router.js';
// Set up link interception
document.querySelectorAll('a[data-navlink]').forEach(a => {
a.addEventListener('click', event => {
event.preventDefault();
Router.go(event.target.getAttribute('href'));
});
});
// Start the app at the current URL
Router.init();The interception setup runs once. Router.init() runs after to catch the initial route. From here, clicking any marked link calls Router.go, which updates the URL and renders the matching view.
Further Reading and Watching
- MDN: History.pushState() - Full signature and behavior for the pushState method.
- MDN: Location.pathname - How to read the current URL path from JavaScript.
Video:
- Build a Single Page App with Vanilla JS by dcode. Walks through building a client-side router from scratch with pushState and link interception.
Keep reading