Building a Client-Side Router from Scratch with Vanilla JavaScript


Modern web applications often need to handle multiple "pages" without actually reloading the browser. This is where client-side routing comes in. In this article, we'll build a powerful, TypeScript-based router for vanilla JavaScript projects that rivals those found in frameworks like React Router or Vue Router.
Why Build a Custom Router?
Before diving in, let's understand why you might want to build your own router:
Framework Independence: Not every project needs a full framework, but most can benefit from proper routing.
Bundle Size: Framework routers often bring additional dependencies and overhead you might not need.
Learning: Understanding how routing works under the hood makes you a better developer, even if you use framework routers in production.
Customization: A custom router can be tailored exactly to your project's needs without compromises.
By the end of this tutorial, you'll understand not just the implementation, but the reasoning behind each design decision.
Understanding Client-Side Routing
What is Client-Side Routing?
Client-side routing allows your application to update the URL and render different content without triggering a full page reload. This creates a smoother user experience similar to native applications.
How Does It Work?
At its core, client-side routing uses two browser APIs:
History API: To change the URL without reloading the page
Event Listeners: To detect URL changes from browser back/forward buttons
Why Not Just Use Server Routing?
Traditional server routing reloads the entire page on navigation, which:
Disrupts user experience with load times and flickering
Loses application state (like scroll position, form inputs)
Requires sending the entire page HTML for each navigation
Increases server load unnecessarily
Designing Our Router
Type Definitions: The Foundation
Let's start by defining our types:
type RouteComponent = (ctx: {
params: Record<string, string>;
search: URLSearchParams;
}) => HTMLElement | HTMLElement[] | Promise<HTMLElement | HTMLElement[]>;
interface Route {
path: string;
component: RouteComponent;
children?: Route[];
}
interface RouterOptions {
devMode?: boolean;
restoreScroll?: boolean;
ssr?: boolean;
}
Our component function can return either a single element or an array of elements, providing flexibility in component structure. The Promise return type enables asynchronous components, which allows for lazy loading, fetching data before rendering, and loading templates from the server.
The router options address common pain points in client-side routing applications. The devMode
option enables detailed logging during development, restoreScroll
solves the UX problem of lost scroll position during navigation, and the ssr
flag accommodates server-side rendering environments. This design makes the router flexible enough for different project requirements while maintaining a clean API.
The Router Class: Core Architecture
export class Router {
private outlet: HTMLElement | null = null;
private staticRoutes = new Map<string, Route>();
private dynamicRoutes: CompiledRoute[] = [];
private listeners = new Set<RouteChangeListener>();
private beforeHooks = new Set<BeforeNavigateHook>();
private afterHooks = new Set<AfterNavigateHook>();
private options: RouterOptions;
private scrollPositions = new Map<string, [number, number]>();
constructor(routes: Route[], outletId: string, options: RouterOptions = {}) {
this.options = options;
if (typeof document !== 'undefined') {
const el = document.getElementById(outletId);
if (!el) throw new Error(`Outlet element #${outletId} not found`);
this.outlet = el;
}
this.compileRoutes(routes);
if (typeof window !== 'undefined') {
window.addEventListener('popstate', () => {
if (this.options.restoreScroll) {
const pos = this.scrollPositions.get(location.pathname);
if (pos) {
setTimeout(() => window.scrollTo(...pos), 0);
}
}
this._handleRouteChange();
});
if (this.options.restoreScroll) {
window.addEventListener('scroll', () => {
this.scrollPositions.set(location.pathname, [window.scrollX, window.scrollY]);
});
}
}
}
}
Our router architecture incorporates several important design decisions that improve performance and user experience. We separate static and dynamic routes using different data structures - a Map
for static routes and an array of compiled routes with RegExp for dynamic routes. This optimization allows for O(1) lookups of static routes, which are typically more common, while still supporting the flexibility of parameterized routes.
For handling route changes, we implement an observer pattern using Set
objects for listeners and hooks. Sets provide automatic deduplication and efficient O(1) addition/removal operations, making the subscription API more performant than an array-based approach.
To improve user experience, we store scroll positions by pathname in a Map and restore them when navigating. This addresses a common UX issue where users lose their position when navigating backward, creating a more natural browsing experience similar to native applications.
The router also includes environment checks using conditions like typeof document !== 'undefined'
, making it compatible with server-side rendering environments where browser objects don't exist.
Route Compilation: Preparing for Efficient Matching
private compileRoutes(routes: Route[], base: string = '') {
for (const route of routes) {
const fullPath =
`${base}/${route.path}`.replace(/\/+/g, '/').replace(/\/$/, '') || '/';
if (!fullPath.includes(':')) {
this.staticRoutes.set(fullPath, route);
} else {
const keys: string[] = [];
const pattern = fullPath.replace(/:([^/]+)/g, (_, key) => {
keys.push(key);
return '([^/]+)';
});
const regex = new RegExp(`^${pattern}$`);
this.dynamicRoutes.push({ regex, keys, route, fullPath });
}
if (route.children) {
this.compileRoutes(route.children, fullPath);
}
}
}
Route compilation works by normalizing paths and preparing them for efficient matching. For each route, we normalize the path by handling duplicate and trailing slashes, ensuring consistent matching regardless of how routes are defined or URLs are entered.
For dynamic routes containing parameters (like /users/:id
), we extract parameter names and create regular expressions that can capture their values. We replace each :paramName
section with a capture group while storing the parameter names for later. This approach lets us extract parameter values during matching and map them back to their named keys, supporting multiple parameters in a single path segment.
We process child routes recursively, passing the parent path as a base. This enables nested route definitions that mirror the component hierarchy, making the route configuration more intuitive and maintainable.
Pre-compiling routes during initialization rather than parsing paths on each navigation improves performance by doing the complex work once, identifies configuration errors early, and allows for efficient matching during runtime.
Route Matching: Finding the Right Component
private matchRoute(pathname: string): RouteMatch | null {
const cleanedPath = pathname.replace(/\/+$/, '') || '/';
// First, try static routes
if (this.staticRoutes.has(cleanedPath)) {
const route = this.staticRoutes.get(cleanedPath)!;
return {
route,
params: {},
search: new URLSearchParams(
typeof window !== 'undefined' ? window.location.search : ''
),
path: pathname,
};
}
// Then, try dynamic routes
for (const entry of this.dynamicRoutes) {
const match = entry.regex.exec(cleanedPath);
if (match) {
const params: Record<string, string> = {};
entry.keys.forEach((key, i) => {
params[key] = decodeURIComponent(match[i + 1]);
});
return {
route: entry.route,
params,
search: new URLSearchParams(
typeof window !== 'undefined' ? window.location.search : ''
),
path: pathname,
};
}
}
return null;
}
Our matching algorithm uses a two-phase approach for optimal performance. First, we clean the path by removing trailing slashes to ensure consistent matching, as users might enter URLs with or without them.
The matching process prioritizes static routes by checking them first. This optimization takes advantage of the faster O(1) Map lookup before attempting the more expensive regex matching required for dynamic routes.
When a dynamic route matches, we extract parameters from the regex capture groups and decode them with decodeURIComponent
. This handles URL-encoded characters (like %20
for spaces) that should be properly decoded before being used by components.
The combined approach delivers both performance for common static routes and flexibility for dynamic parameterized routes.
Handling Route Changes: The Core Logic
private async _handleRouteChange() {
const match = this.matchRoute(location.pathname);
if (!match) {
this.log('No route matched:', location.pathname);
this.outlet?.replaceChildren(this.createElement('h2', '404 - Not Found'));
return;
}
// Notify before/transition hooks
this.notifyHooks(match);
const result = await match.route.component({
params: match.params,
search: match.search,
});
if (result instanceof HTMLElement) {
this.outlet?.replaceChildren(result);
} else if (Array.isArray(result)) {
this.outlet?.replaceChildren(...result);
} else {
this.outlet?.replaceChildren(this.createElement('div', 'Invalid route output'));
}
// Notify after navigation hooks
this.notifyAfterHooks(match);
}
The route change handler is the heart of our router. It uses the async/await pattern to handle both synchronous and asynchronous components. This creates a unified API that supports various component types without requiring the consumer to handle promises explicitly.
For DOM manipulation, we use replaceChildren()
instead of setting innerHTML
. This approach is both safer (avoiding potential XSS vulnerabilities) and more efficient as it directly manipulates the DOM without string parsing.
We include type checking for component results, verifying if the result is an HTMLElement or an array. This provides flexibility while ensuring correct handling of each case.
Our hook execution follows a specific order - listener hooks run before rendering and after hooks run after rendering. This allows for different use cases, where pre-render hooks can set up loading states while post-render hooks can perform DOM manipulations or analytics tracking.
Navigation Methods: Controlling the Browser History
public navigate(path: string): void {
if (!this.runBeforeHooks(path)) return;
history.pushState(null, '', path);
this._handleRouteChange();
}
public replace(path: string): void {
if (!this.runBeforeHooks(path)) return;
history.replaceState(null, '', path);
this._handleRouteChange();
}
public goBack(): void {
history.back();
}
public canGoBack(): boolean {
return history.length > 1;
}
Our navigation methods directly leverage the History API for smooth, no-reload page transitions. Methods like pushState
and replaceState
allow URL changes without triggering page reloads, creating a more app-like user experience.
Before changing the URL, we run navigation hooks to enable protection patterns like "unsaved changes" warnings or authentication checks. If any hook returns false, navigation is canceled.
Since pushState
and replaceState
don't trigger popstate
events automatically, we manually call _handleRouteChange()
after updating the URL. This ensures components are updated to match the new URL.
We also provide convenient wrappers around native history methods like history.back()
to create a more intuitive API and allow for future enhancements like hook execution for back navigation.
Navigation Hooks: Adding Control Flow
public beforeNavigate(callback: BeforeNavigateHook): void {
this.beforeHooks.add(callback);
}
public afterNavigate(callback: AfterNavigateHook): void {
this.afterHooks.add(callback);
}
private runBeforeHooks(path: string): boolean {
for (const guard of this.beforeHooks) {
if (!guard(path)) return false;
}
return true;
}
private notifyHooks(match: RouteMatch) {
for (const cb of this.listeners) {
cb(match);
}
}
private notifyAfterHooks(match: RouteMatch) {
for (const cb of this.afterHooks) {
cb(match);
}
}
Navigation hooks serve several critical purposes in our router. Before-hooks can return false
to cancel navigation, enabling protection patterns like unsaved change warnings, authentication guards, or permission checks. This prevents users from navigating to routes they shouldn't access or accidentally losing unsaved work.
After-hooks run once navigation is complete, enabling important side effects like analytics tracking, document title updates, focus management for accessibility, and application state synchronization. These hooks make it easier to keep various parts of your application in sync with the current route.
Our implementation uses the observer pattern with Set
objects and callback functions. This creates a clean subscription system that allows different parts of the application to react to route changes without tight coupling, improving code organization and maintainability.
Practical Application: Using the Router
import { Router } from './router';
// Define your routes
const routes = [
{
path: '/',
component: () => {
const homeEl = document.createElement('div');
const h1 = document.createElement('h1');
h1.textContent = 'Home Page';
homeEl.appendChild(h1);
const p = document.createElement('p');
p.textContent = 'Welcome to the home page.';
homeEl.appendChild(p);
return homeEl;
},
},
{
path: '/users/:id',
component: ({ params, search }) => {
const container = document.createElement('div');
const header = document.createElement('h2');
header.textContent = `User: ${params.id}`;
container.appendChild(header);
const queryInfo = document.createElement('p');
queryInfo.textContent = `Query param q: ${search.get('q') || 'none'}`;
container.appendChild(queryInfo);
return container;
},
},
];
// Instantiate the router
const router = new Router(routes, 'app', {
devMode: true,
restoreScroll: true,
});
// Example subscription and guard hooks
router.onRouteChange(match => {
console.log('Route changed:', match.path);
document.title = `My App - ${match.path}`;
});
router.beforeNavigate(path => {
if (path === '/admin' && !isLoggedIn()) {
alert('Please log in first');
router.navigate('/login');
return false;
}
return true;
});
// Initial render
router.render();
Our implementation uses a component factory pattern where each route defines a function that creates DOM elements. This creates a clean separation between routing and component rendering while avoiding string-based templates that could introduce security issues.
Routes are defined in a declarative configuration style using simple JavaScript objects. This makes the routing configuration easy to understand and maintain, similar to popular framework routers.
We pass params
and search
to components as structured objects, eliminating the need for global state or complex parameter extraction in components. This contextual parameter passing keeps components pure and testable.
Advanced Usage: Component Patterns
Let's look at some advanced patterns our router enables:
// Server-loaded component
{
path: '/server',
component: async () => {
try {
const response = await fetch('/server-template.html');
if (!response.ok) throw new Error('Failed to load template');
const htmlText = await response.text();
// Parse the fetched HTML string into a document
const parser = new DOMParser();
const doc = parser.parseFromString(htmlText, 'text/html');
// Assume your server template has an element with ID "server-content"
const container = doc.getElementById('server-content') || doc.body;
return container;
} catch (error) {
const errorEl = document.createElement('div');
errorEl.textContent = 'Error loading server UI.';
return errorEl;
}
},
},
// Template-based component
{
path: '/template',
component: () => {
const template = document.getElementById('my-template') as HTMLTemplateElement;
if (template && template.content) {
// Use document.importNode to clone the template content
return document.importNode(template.content, true);
}
const fallback = document.createElement('div');
fallback.textContent = 'Template not found';
return fallback;
},
},
Our router supports advanced component patterns that enhance flexibility. Server-loaded components fetch HTML from the server and parse it into DOM elements. This enables a hybrid rendering approach combining server and client rendering benefits - server-rendered content can be enhanced on the client after loading.
Template-based components use HTML <template>
elements and document.importNode
. Templates provide a clean way to define markup in HTML files rather than JavaScript strings, improving maintainability and enabling better tooling support.
We implement comprehensive error handling with try/catch blocks and fallback content. This creates a more resilient application that gracefully handles network failures or missing templates, preventing broken UI states.
Performance Considerations
Our router design incorporates several performance optimizations. The separation of static and dynamic routes uses data structures optimized for each case. Since most applications have more static routes than dynamic ones, optimizing for static route lookups improves average performance.
We preprocess routes once at initialization, frontloading expensive path parsing operations so they don't impact navigation performance. This compiled routes approach means each navigation operation benefits from the work done upfront.
For DOM operations, we use replaceChildren
instead of manipulating innerHTML. Minimizing and optimizing DOM operations improves render performance, especially for complex components with many elements.
What's Missing? Future Enhancements
While our router is already powerful, several advanced features could enhance it further:
Lazy Loading: Supporting dynamic imports for component modules would enable code-splitting, improving initial load time by only loading components when needed.
Route-Specific Guards: Adding guard functions to individual route definitions would allow for more granular control over navigation without having to check paths in global guards.
Nested Rendering: Implementing an outlet system for child routes would allow parent components to remain mounted while child components change, enabling more complex UI patterns.
Conclusion: Why Vanilla JS Routing Matters
Building your own router from scratch gives you not just a valuable tool for vanilla JavaScript projects, but a deeper understanding of how web applications work under the hood. By understanding the reasoning behind each implementation decision, you can:
Make better architectural choices in any framework
Debug routing issues more effectively
Create more performant and maintainable applications
Reduce dependencies and improve bundle size
Remember that modern web development doesn't always require a heavy framework. With a solid understanding of browser APIs and a well-designed router like the one we've built, you can create sophisticated single-page applications using vanilla JavaScript that deliver excellent user and developer experiences.
Whether you use this router in production or simply learn from its concepts, the principles behind efficient client-side routing remain valuable across the entire frontend ecosystem.
Subscribe to my newsletter
Read articles from GASTON CHE directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
