Skip to main content
Back to Blog
Tutorials
3 min read
December 28, 2024

How to Build a URL Router from Scratch in JavaScript

Build a client-side URL router from scratch in JavaScript with path parameters, wildcards, middleware, nested routes, and history management.

Ryel Banfield

Founder & Lead Developer

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.

routerroutingJavaScriptSPAtutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles