Creating an SPA Router with the JS History API
Build a lightweight single-page application router from scratch using the History API. Learn route matching, view rendering, and Back button handling.
A single-page application router connects URLs to view functions without reloading the page. When the user clicks a link, the URL changes and new content appears, but the browser never navigates away. Frameworks like React Router and Vue Router do this, but the core logic is a small amount of code built on the History API.
This article walks through building a working SPA router step by step. By the end you will have a router that supports route matching, link interception, Back button navigation, and parameterized routes.
Setting up the route table
Start with a simple array that maps URL patterns to render functions. Each route has a pattern string and a view callback.
const routes = [
{ pattern: "/", view: () => "<h2>Home</h2><p>Welcome to the app.</p>" },
{ pattern: "/about", view: () => "<h2>About</h2><p>Learn more here.</p>" },
{ pattern: "/contact", view: () => "<h2>Contact</h2><p>Get in touch.</p>" },
];The view functions return HTML strings. In a real app they would create DOM elements or trigger a component render, but the router logic stays the same.
Matching routes to patterns
The router needs a function that takes a pathname, finds the matching route, and calls its view. For static routes, a simple find works.
function matchRoute(pathname) {
return routes.find((route) => route.pattern === pathname);
}
function render(pathname) {
const route = matchRoute(pathname);
const app = document.querySelector("#app");
if (route) {
app.innerHTML = route.view();
} else {
app.innerHTML = "<h2>404</h2><p>Page not found.</p>";
}
}The render function clears the app container and inserts the matched view. If no route matches, it shows a 404 page. This handles the initial render, but links still cause full page reloads.
Intercepting link clicks
To prevent full navigation, listen for clicks on internal links. When a link points to the same origin, cancel the default behavior and use the router instead.
document.addEventListener("click", (event) => {
const link = event.target.closest("a");
if (!link) return;
const url = new URL(link.href);
if (url.origin !== location.origin) return;
event.preventDefault();
history.pushState({}, "", url.pathname);
render(url.pathname);
});The click handler finds the nearest anchor element, checks that it is an internal link, cancels the default navigation, pushes a new history entry, and renders the matching view. Now clicking links updates the URL and content without a page reload.
Handling the Back and Forward buttons
The popstate event fires when the user navigates with the browser buttons. The router must listen for it and render the view for the updated pathname.
window.addEventListener("popstate", () => {
render(location.pathname);
});With this one line, the Back and Forward buttons work correctly. Each time the user navigates, the router renders the right view for the current URL.
Adding parameterized routes
Most apps need routes like /users/42 that extract a dynamic segment. Start by writing a function that converts colon-prefixed patterns into regular expressions:
function buildMatcher(pattern) {
const paramNames = [];
const regexString = pattern.replace(/:(\w+)/g, (_, name) => {
paramNames.push(name);
return "([^/]+)";
});
const regex = new RegExp(`^${regexString}$`);
return { regex, paramNames };
}The buildMatcher function finds :param segments, creates a regex that captures those positions, and maps matched values back to parameter names.
Now update the matchRoute function to use this matcher and extract parameters:
function matchRoute(pathname) {
for (const route of routes) {
const { regex, paramNames } = buildMatcher(route.pattern);
const match = pathname.match(regex);
if (match) {
const params = {};
paramNames.forEach((name, i) => {
params[name] = match[i + 1];
});
return { ...route, params };
}
}
return null;
}The buildMatcher function finds :param segments in the pattern, creates a regex that captures those positions, and maps matched values back to parameter names. The router now supports routes like /users/:id.
With the new matcher, routes with parameters work naturally:
const routes = [
{ pattern: "/", view: () => "<h2>Home</h2>" },
{
pattern: "/users/:id",
view: (params) => `<h2>User ${params.id}</h2><p>Profile page.</p>`,
},
];The view function receives the params object with the extracted id value, so it can fetch the right data or render the appropriate content.
Putting it all together
The complete router initializes on page load, handles link clicks, and responds to browser navigation. Here is the initialization wiring:
document.addEventListener("DOMContentLoaded", () => {
render(location.pathname);
});
document.addEventListener("click", (event) => {
const link = event.target.closest("a");
if (!link) return;
const url = new URL(link.href);
if (url.origin !== location.origin) return;
event.preventDefault();
history.pushState({}, "", url.pathname);
render(url.pathname);
});The DOMContentLoaded handler renders the initial route when the page first loads. The click handler intercepts same-origin link clicks and routes them through pushState instead of letting the browser navigate. Together these two listeners handle all forward navigation.
Now add the popstate listener so the Back and Forward buttons also work correctly:
window.addEventListener("popstate", () => {
render(location.pathname);
});This is about 20 lines of routing logic. Real frameworks add nested routes, lazy loading, guards, and transition animations, but they all build on these same three primitives from the History API.
Common mistakes
- Forgetting to call render on page load. The initial URL must be handled, not just future clicks and popstate events.
- Not preventing default on intercepted links. The browser follows the href normally unless you call preventDefault.
- Ignoring external links and anchor links. Only intercept same-origin navigation. Let external links and hash anchors behave normally.
- Expecting pushState to trigger popstate. It does not. You must call render manually after pushState.
- Skipping the 404 case. Every router needs a fallback for unmatched routes.
Related articles
- JavaScript History API Guide: Complete Tutorial -- the pushState, replaceState, and popstate primitives used by this router
- How to Add Event Listeners in JS: Complete Guide -- event delegation patterns used in link interception
- Handling Click Events in JavaScript: Full Guide -- click handling for navigation links
Quick reference
| Concern | Implementation |
|---|---|
| Define routes | Array of { pattern, view } objects |
| Match a path | Convert pattern to regex, test against pathname |
| Render a view | Call the matched view function, insert into the DOM |
| Intercept links | Global click listener with preventDefault and pushState |
| Handle Back/Forward | popstate event listener calls render |
| Parameters | Colon-prefixed segments in patterns become capture groups |
| Initial load | Call render with location.pathname on DOMContentLoaded |
Rune AI
Key Insights
- Define routes as an array of pattern-view pairs with parameterized segments.
- Intercept link clicks with a global event listener and call pushState.
- Use the popstate event to render the correct view on Back and Forward navigation.
- Match routes by converting patterns to regular expressions and extracting parameters.
- Call the router on page load to render the initial route before any navigation happens.
Frequently Asked Questions
Do I need a framework to build a client-side router?
How does this router handle page reloads?
Can this router handle dynamic route parameters like /users/42?
Should I use hash-based routing instead?
Conclusion
A client-side router built on the History API needs three pieces: a route table mapping patterns to view functions, link interception that calls pushState instead of following hrefs, and a popstate handler that renders the correct view when the user presses Back or Forward. This small router covers all three and gives you a foundation to extend with nested routes, guards, and lazy loading.
More in this topic
Using Decorators for Logging in JS Architecture
Learn how JavaScript decorators wrap class methods to add logging without touching the original code, including setup with Babel and how execution order works.
JavaScript While Loop Explained: A Complete Guide
A while loop repeats code as long as a condition stays true. Learn the syntax, see practical examples, and understand when a while loop is the right choice over a for loop.
JavaScript Loops Tutorial: for, while, do while
Loops let JavaScript repeat code without writing it twice. Learn the three core loop types, what each one does, and when to choose one over another.