Understanding routing internals helps you build better apps. Here is a fully-featured client-side router.
Core Router
// lib/router.ts
type RouteHandler = (params: Record<string, string>, query: URLSearchParams) => void;
type Middleware = (
params: Record<string, string>,
query: URLSearchParams,
next: () => void
) => void;
interface Route {
pattern: string;
regex: RegExp;
paramNames: string[];
handler: RouteHandler;
middlewares: Middleware[];
}
export class Router {
private routes: Route[] = [];
private globalMiddlewares: Middleware[] = [];
private notFoundHandler: RouteHandler = () => {
console.warn("404: No matching route");
};
use(middleware: Middleware) {
this.globalMiddlewares.push(middleware);
return this;
}
on(pattern: string, handler: RouteHandler, middlewares: Middleware[] = []) {
const { regex, paramNames } = this.compilePattern(pattern);
this.routes.push({ pattern, regex, paramNames, handler, middlewares });
return this;
}
notFound(handler: RouteHandler) {
this.notFoundHandler = handler;
return this;
}
private compilePattern(pattern: string): { regex: RegExp; paramNames: string[] } {
const paramNames: string[] = [];
const regexStr = pattern
.replace(/\/:(\w+)/g, (_, name) => {
paramNames.push(name);
return "/([^/]+)";
})
.replace(/\*/g, ".*");
return {
regex: new RegExp(`^${regexStr}/?$`),
paramNames,
};
}
resolve(path: string): { handler: RouteHandler; params: Record<string, string>; middlewares: Middleware[] } | null {
for (const route of this.routes) {
const match = path.match(route.regex);
if (match) {
const params: Record<string, string> = {};
route.paramNames.forEach((name, i) => {
params[name] = decodeURIComponent(match[i + 1]);
});
return {
handler: route.handler,
params,
middlewares: [...this.globalMiddlewares, ...route.middlewares],
};
}
}
return null;
}
navigate(url: string) {
const [path, queryString] = url.split("?");
const query = new URLSearchParams(queryString ?? "");
const result = this.resolve(path);
if (!result) {
this.notFoundHandler({}, query);
return;
}
const { handler, params, middlewares } = result;
// Execute middleware chain
let index = 0;
const next = () => {
if (index < middlewares.length) {
const mw = middlewares[index++];
mw(params, query, next);
} else {
handler(params, query);
}
};
next();
}
}
Browser History Integration
// lib/browser-router.ts
import { Router } from "./router";
export class BrowserRouter extends Router {
private listeners: (() => void)[] = [];
start() {
// Listen for popstate (back/forward buttons)
const handler = () => this.handleCurrentUrl();
window.addEventListener("popstate", handler);
this.listeners.push(() => window.removeEventListener("popstate", handler));
// Intercept link clicks
const clickHandler = (e: MouseEvent) => {
const anchor = (e.target as HTMLElement).closest("a");
if (!anchor) return;
const href = anchor.getAttribute("href");
if (!href || href.startsWith("http") || href.startsWith("#")) return;
e.preventDefault();
this.push(href);
};
document.addEventListener("click", clickHandler);
this.listeners.push(() => document.removeEventListener("click", clickHandler));
// Handle initial URL
this.handleCurrentUrl();
return this;
}
stop() {
this.listeners.forEach((fn) => fn());
this.listeners = [];
}
push(url: string) {
window.history.pushState(null, "", url);
this.handleCurrentUrl();
}
replace(url: string) {
window.history.replaceState(null, "", url);
this.handleCurrentUrl();
}
back() {
window.history.back();
}
private handleCurrentUrl() {
const path = window.location.pathname;
const url = path + window.location.search;
this.navigate(url);
}
}
Middleware Examples
// middlewares/auth.ts
import type { Middleware } from "./router";
export function authMiddleware(isAuthenticated: () => boolean): Middleware {
return (_params, _query, next) => {
if (!isAuthenticated()) {
window.history.replaceState(null, "", "/login");
return;
}
next();
};
}
// middlewares/logger.ts
export const loggerMiddleware: Middleware = (params, query, next) => {
const start = performance.now();
console.log(`[Router] ${window.location.pathname}`, { params, query: Object.fromEntries(query) });
next();
console.log(`[Router] Handled in ${(performance.now() - start).toFixed(2)}ms`);
};
Usage
import { BrowserRouter } from "./lib/browser-router";
import { loggerMiddleware, authMiddleware } from "./middlewares";
const router = new BrowserRouter();
// Global middleware
router.use(loggerMiddleware);
// Public routes
router.on("/", () => {
document.getElementById("app")!.innerHTML = "<h1>Home</h1>";
});
router.on("/about", () => {
document.getElementById("app")!.innerHTML = "<h1>About</h1>";
});
// Dynamic parameters
router.on("/blog/:slug", (params) => {
document.getElementById("app")!.innerHTML = `<h1>Post: ${params.slug}</h1>`;
});
router.on("/users/:id/posts/:postId", (params) => {
document.getElementById("app")!.innerHTML =
`<h1>User ${params.id}, Post ${params.postId}</h1>`;
});
// Protected routes with middleware
router.on(
"/dashboard",
() => {
document.getElementById("app")!.innerHTML = "<h1>Dashboard</h1>";
},
[authMiddleware(() => !!localStorage.getItem("token"))]
);
// Wildcard
router.on("/docs/*", () => {
document.getElementById("app")!.innerHTML = "<h1>Documentation</h1>";
});
// 404
router.notFound(() => {
document.getElementById("app")!.innerHTML = "<h1>404 Not Found</h1>";
});
router.start();
React Integration
// hooks/useRouter.ts
"use client";
import { useSyncExternalStore, useCallback } from "react";
let currentPath = typeof window !== "undefined" ? window.location.pathname : "/";
const listeners = new Set<() => void>();
function subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function useRouter() {
const path = useSyncExternalStore(
subscribe,
() => currentPath,
() => "/"
);
const push = useCallback((url: string) => {
window.history.pushState(null, "", url);
currentPath = url.split("?")[0];
listeners.forEach((fn) => fn());
}, []);
return { path, push };
}
Want Custom Web Architecture?
We build tailor-made web solutions with the right level of abstraction. Contact us to explore your options.