Creating an SPA Router With the JS History API
A complete tutorial on creating a single-page application router with the JavaScript History API. Covers route registration, dynamic path parameters, wildcard routes, nested routes, route guards, lazy loading, query parameter parsing, transition hooks, link interception, and building a production-ready client-side router from scratch.
A client-side router intercepts link clicks, updates the URL with pushState, and renders the correct view without a full page reload. This guide builds a complete router from scratch with dynamic parameters, guards, lazy loading, and middleware.
For the underlying History API reference, see JavaScript History API guide: complete tutorial.
Router Core
class Router {
constructor(options = {}) {
this.routes = [];
this.notFoundHandler = null;
this.currentRoute = null;
this.rootElement = document.querySelector(options.root || "#app");
this.middleware = [];
// Listen for back/forward navigation
window.addEventListener("popstate", () => this.resolve());
// Intercept link clicks
document.addEventListener("click", (event) => {
const link = event.target.closest("a[href]");
if (!link) return;
const href = link.getAttribute("href");
// Only intercept local links
if (
link.target === "_blank" ||
link.hasAttribute("download") ||
href.startsWith("http") ||
href.startsWith("mailto:") ||
href.startsWith("#")
) {
return;
}
event.preventDefault();
this.navigate(href);
});
}
route(path, handler, options = {}) {
const paramNames = [];
const pattern = path.replace(/:([^/]+)/g, (_, name) => {
paramNames.push(name);
return "([^/]+)";
});
this.routes.push({
path,
pattern: new RegExp(`^${pattern}$`),
paramNames,
handler,
...options,
});
return this;
}
notFound(handler) {
this.notFoundHandler = handler;
return this;
}
use(fn) {
this.middleware.push(fn);
return this;
}
navigate(path, state = {}) {
history.pushState(state, "", path);
this.resolve();
}
replace(path, state = {}) {
history.replaceState(state, "", path);
this.resolve();
}
async resolve() {
const path = window.location.pathname;
const query = Object.fromEntries(new URLSearchParams(window.location.search));
for (const route of this.routes) {
const match = path.match(route.pattern);
if (match) {
const params = {};
route.paramNames.forEach((name, i) => {
params[name] = decodeURIComponent(match[i + 1]);
});
const context = {
path,
params,
query,
state: history.state || {},
route: route.path,
};
// Run middleware
const allowed = await this.runMiddleware(context);
if (!allowed) return;
// Run route guard
if (route.guard) {
const guardResult = await route.guard(context);
if (!guardResult) return;
}
this.currentRoute = context;
// Handle lazy loading
if (route.lazy) {
const module = await route.lazy();
module.default(this.rootElement, context);
} else {
route.handler(this.rootElement, context);
}
return;
}
}
// No route matched
if (this.notFoundHandler) {
this.notFoundHandler(this.rootElement, { path, query });
}
}
async runMiddleware(context) {
for (const fn of this.middleware) {
const result = await fn(context);
if (result === false) return false;
}
return true;
}
start() {
this.resolve();
return this;
}
}Registering Routes
const router = new Router({ root: "#app" });
// Static routes
router.route("/", (el) => {
el.innerHTML = `
<h1>Home</h1>
<nav>
<a href="/about">About</a>
<a href="/articles">Articles</a>
</nav>
`;
});
router.route("/about", (el) => {
el.innerHTML = "<h1>About Us</h1><p>We build tutorials.</p>";
});
// Dynamic route with parameters
router.route("/articles/:id", (el, { params, query }) => {
el.innerHTML = `
<h1>Article ${params.id}</h1>
<p>Query: ${JSON.stringify(query)}</p>
<a href="/articles">Back to list</a>
`;
});
// Nested dynamic parameters
router.route("/users/:userId/posts/:postId", (el, { params }) => {
el.innerHTML = `
<h1>User ${params.userId} - Post ${params.postId}</h1>
`;
});
// 404 handler
router.notFound((el, { path }) => {
el.innerHTML = `<h1>404</h1><p>Page not found: ${path}</p>`;
});
router.start();Route Guards
function isAuthenticated() {
return !!localStorage.getItem("authToken");
}
// Protect specific routes
router.route(
"/dashboard",
(el) => {
el.innerHTML = "<h1>Dashboard</h1><p>Welcome back!</p>";
},
{
guard: () => {
if (!isAuthenticated()) {
router.navigate("/login");
return false;
}
return true;
},
}
);
router.route(
"/admin/:section",
(el, { params }) => {
el.innerHTML = `<h1>Admin: ${params.section}</h1>`;
},
{
guard: () => {
const user = JSON.parse(localStorage.getItem("user") || "{}");
if (user.role !== "admin") {
router.navigate("/");
return false;
}
return true;
},
}
);
// Global middleware (runs for every route)
router.use((context) => {
console.log(`Navigating to: ${context.path}`);
return true; // Allow navigation
});
// Auth middleware
router.use((context) => {
const publicPaths = ["/", "/about", "/login", "/register"];
if (publicPaths.includes(context.path)) return true;
if (!isAuthenticated()) {
router.navigate(`/login?redirect=${encodeURIComponent(context.path)}`);
return false;
}
return true;
});Lazy Loading Routes
// Load route handlers on demand
router.route("/settings", null, {
lazy: () => import("./pages/settings.js"),
});
router.route("/analytics", null, {
lazy: () => import("./pages/analytics.js"),
});
// pages/settings.js
export default function settingsPage(el, context) {
el.innerHTML = `
<h1>Settings</h1>
<form id="settings-form">
<label>Theme: <select name="theme">
<option>light</option>
<option>dark</option>
</select></label>
</form>
`;
}Query Parameter Handling
class QueryParams {
constructor() {
this.params = new URLSearchParams(window.location.search);
}
get(key, defaultValue = null) {
return this.params.get(key) ?? defaultValue;
}
getAll(key) {
return this.params.getAll(key);
}
getNumber(key, defaultValue = 0) {
const value = this.params.get(key);
if (value === null) return defaultValue;
const num = Number(value);
return Number.isNaN(num) ? defaultValue : num;
}
getBoolean(key, defaultValue = false) {
const value = this.params.get(key);
if (value === null) return defaultValue;
return value === "true" || value === "1";
}
set(key, value) {
this.params.set(key, value);
this.updateURL();
}
delete(key) {
this.params.delete(key);
this.updateURL();
}
updateURL() {
const search = this.params.toString();
const newURL = search
? `${window.location.pathname}?${search}`
: window.location.pathname;
history.replaceState(history.state, "", newURL);
}
toObject() {
return Object.fromEntries(this.params);
}
}
// Usage in route handler
router.route("/search", (el, { query }) => {
const qp = new QueryParams();
const term = qp.get("q", "");
const page = qp.getNumber("page", 1);
el.innerHTML = `
<h1>Search: "${term}" (Page ${page})</h1>
<input id="search-input" value="${term}" />
`;
document.getElementById("search-input").addEventListener("change", (e) => {
qp.set("q", e.target.value);
qp.set("page", "1");
});
});Page Transitions
class TransitionRouter extends Router {
constructor(options = {}) {
super(options);
this.transitionDuration = options.transitionDuration || 300;
}
async resolve() {
const el = this.rootElement;
// Fade out
el.style.transition = `opacity ${this.transitionDuration}ms ease`;
el.style.opacity = "0";
await new Promise((r) => setTimeout(r, this.transitionDuration));
// Render new content
await super.resolve();
// Fade in
el.style.opacity = "1";
}
}
const router = new TransitionRouter({
root: "#app",
transitionDuration: 200,
});Router Feature Comparison
| Feature | Hash Router | History Router | Navigation API |
|---|---|---|---|
| URL format | /#/path | /path | /path |
| Server config needed | No | Yes (catch-all) | Yes |
| SEO friendly | No | Yes | Yes |
| Browser support | All | IE10+ | Chrome 102+ |
| popstate event | hashchange | popstate | navigate |
| State object | Manual | Built-in | Built-in |
Active Link Highlighting
function updateActiveLinks() {
const currentPath = window.location.pathname;
document.querySelectorAll("a[href]").forEach((link) => {
const href = link.getAttribute("href");
if (href === currentPath) {
link.classList.add("active");
link.setAttribute("aria-current", "page");
} else {
link.classList.remove("active");
link.removeAttribute("aria-current");
}
});
}
// Call after every navigation
router.use((context) => {
requestAnimationFrame(updateActiveLinks);
return true;
});Rune AI
Key Insights
- Intercept link clicks: Prevent default on same-origin
<a>clicks and callpushStateto update the URL without reloading - Match routes with regex: Convert dynamic
:paramsegments to capture groups and extract parameters from the match - Route guards prevent unauthorized access: Return
falsefrom a guard to cancel navigation and redirect to login or home - Lazy loading reduces bundle size: Use dynamic
import()for route handlers to load code only when the route is visited - Server must serve index.html for all routes: Without a catch-all rule, direct URL access or page refresh returns 404 from the server
Frequently Asked Questions
Do I need server-side configuration for History API routing?
How do I handle 404 pages with a client-side router?
Can I mix server-rendered and client-rendered pages?
How do I handle scroll position with client-side routing?
What is the Navigation API and should I use it instead?
Conclusion
A History API-based SPA router intercepts link clicks, uses pushState to update the URL, matches paths against registered routes, and renders content without page reloads. Add route guards for authentication, lazy loading for performance, and middleware for logging and analytics. For the underlying API, see JavaScript History API guide. For related browser storage, see JS sessionStorage API guide.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.