FossilsðŸ’ŧ CodingImplement a Simple Client-Side Router
ðŸĶ–DinosaurRouterSPAImplementation

Implement a Simple Client-Side Router

Building a router from scratch demonstrates mastery of the History API, URL parsing, and the core abstraction behind React Router.

Implement a Simple Client-Side Router

Interview Question: "Implement a simple client-side router."

Understanding Client-Side Routing

"A client-side router intercepts navigation, prevents full page reloads, and renders the correct view based on the URL. There are two approaches: hash-based (#/path) and History API-based (/path). Hash-based is simpler and works everywhere; History API is cleaner but needs server-side fallback."

Version 1: Hash-Based Router

class HashRouter {
  constructor() {
    this.routes = new Map();
    this.notFound = () => {};
 
    window.addEventListener("hashchange", () => this.handleRoute());
    window.addEventListener("DOMContentLoaded", () => this.handleRoute());
  }
 
  addRoute(path, handler) {
    this.routes.set(path, handler);
    return this; // chainable
  }
 
  setNotFound(handler) {
    this.notFound = handler;
    return this;
  }
 
  navigate(path) {
    window.location.hash = `#${path}`;
  }
 
  handleRoute() {
    const hash = window.location.hash.slice(1) || "/";
    const { handler, params } = this.matchRoute(hash);
 
    if (handler) {
      handler({ params, path: hash });
    } else {
      this.notFound({ path: hash });
    }
  }
 
  matchRoute(path) {
    for (const [pattern, handler] of this.routes) {
      const params = matchPath(pattern, path);
      if (params !== null) {
        return { handler, params };
      }
    }
    return { handler: null, params: {} };
  }
}

Usage:

const router = new HashRouter();
 
router
  .addRoute("/", ({ params }) => renderHome())
  .addRoute("/users", ({ params }) => renderUserList())
  .addRoute("/users/:id", ({ params }) => renderUser(params.id))
  .addRoute("/posts/:id/comments/:commentId", ({ params }) => {
    renderComment(params.id, params.commentId);
  })
  .setNotFound(() => render404());

Version 2: History API Router

class Router {
  constructor(rootElement) {
    this.routes = new Map();
    this.root = rootElement;
    this.notFound = () => {};
 
    window.addEventListener("popstate", () => this.handleRoute());
    this.interceptClicks();
  }
 
  addRoute(path, handler) {
    this.routes.set(path, handler);
    return this;
  }
 
  setNotFound(handler) {
    this.notFound = handler;
    return this;
  }
 
  navigate(path, { replace = false } = {}) {
    if (replace) {
      history.replaceState(null, "", path);
    } else {
      history.pushState(null, "", path);
    }
    this.handleRoute();
  }
 
  interceptClicks() {
    document.addEventListener("click", (e) => {
      const anchor = e.target.closest("a[data-route]");
      if (!anchor) return;
 
      e.preventDefault();
      const path = anchor.getAttribute("href");
      this.navigate(path);
    });
  }
 
  handleRoute() {
    const path = window.location.pathname;
    const { handler, params } = this.matchRoute(path);
    const query = Object.fromEntries(new URLSearchParams(window.location.search));
 
    if (handler) {
      handler({ params, query, path });
    } else {
      this.notFound({ path });
    }
  }
 
  matchRoute(path) {
    for (const [pattern, handler] of this.routes) {
      const params = matchPath(pattern, path);
      if (params !== null) {
        return { handler, params };
      }
    }
    return { handler: null, params: {} };
  }
}

Key Differences from Hash Router

  • history.pushState — changes URL without triggering a page reload
  • popstate event — fires when user clicks Back/Forward (but NOT on pushState)
  • Click interception — anchor clicks are intercepted to prevent default navigation
  • Server fallback required — the server must return index.html for all routes (otherwise direct URL access returns 404)

Route Matching (Shared by Both Routers)

function matchPath(pattern, path) {
  const patternParts = pattern.split("/").filter(Boolean);
  const pathParts = path.split("/").filter(Boolean);
 
  if (patternParts.length !== pathParts.length) {
    const lastPattern = patternParts[patternParts.length - 1];
    if (lastPattern !== "*" && patternParts.length !== pathParts.length) {
      return null;
    }
  }
 
  const params = {};
 
  for (let i = 0; i < patternParts.length; i++) {
    const pat = patternParts[i];
    const val = pathParts[i];
 
    if (pat === "*") {
      params["*"] = pathParts.slice(i).join("/");
      return params;
    }
 
    if (pat.startsWith(":")) {
      params[pat.slice(1)] = decodeURIComponent(val);
      continue;
    }
 
    if (pat !== val) return null;
  }
 
  return params;
}

Supported patterns:

matchPath("/users/:id", "/users/42")
// → { id: "42" }
 
matchPath("/posts/:id/comments/:commentId", "/posts/5/comments/12")
// → { id: "5", commentId: "12" }
 
matchPath("/files/*", "/files/docs/readme.md")
// → { "*": "docs/readme.md" }
 
matchPath("/about", "/about")
// → {}
 
matchPath("/about", "/contact")
// → null (no match)

Adding Route Guards

class GuardedRouter extends Router {
  constructor(rootElement) {
    super(rootElement);
    this.guards = [];
  }
 
  addGuard(guardFn) {
    this.guards.push(guardFn);
    return this;
  }
 
  async handleRoute() {
    const path = window.location.pathname;
 
    for (const guard of this.guards) {
      const result = await guard({ path });
      if (result === false) return;
      if (typeof result === "string") {
        this.navigate(result, { replace: true });
        return;
      }
    }
 
    super.handleRoute();
  }
}
 
const router = new GuardedRouter(document.getElementById("app"));
 
router.addGuard(({ path }) => {
  if (path.startsWith("/admin") && !isAuthenticated()) {
    return "/login"; // redirect
  }
  return true; // allow
});

How React Router Works Under the Hood

"React Router builds on these same primitives but adds React integration:"

  1. <BrowserRouter> — creates the History API wrapper and provides it via Context
  2. <Routes> / <Route> — declarative route definitions that React Router converts into a route config tree
  3. useNavigate() — calls history.pushState + triggers a context update to re-render
  4. useParams() — reads extracted params from the matched route context
  5. <Outlet> — renders the matched child route (enables nested routing)
URL change (popstate or navigate())
    → Router context updates
    → React re-renders from Routes downward
    → Route matching runs against the config tree
    → Matched route's element renders
    → Nested <Outlet> renders child matches

"The key insight is that React Router is just React state management — URL changes trigger context updates, which trigger re-renders. The matching algorithm is similar to what we built, but it handles ranked matching (more specific routes score higher)."

Nested Routes (Conceptual)

const routes = {
  "/": { handler: renderLayout, children: {
    "": { handler: renderHome },
    "users": { handler: renderUsersLayout, children: {
      "": { handler: renderUserList },
      ":id": { handler: renderUserDetail },
    }},
    "settings": { handler: renderSettings },
  }},
};

"Nested routing renders parent layouts that contain an outlet for child routes. /users/42 renders both renderUsersLayout (with navigation) and renderUserDetail (within the outlet). This is how frameworks like React Router and Next.js App Router work — each segment of the URL maps to a layout layer."

What Interviewers Look For

  • Two approaches — hash vs History API, trade-offs of each
  • pushState doesn't fire popstate — you must call handleRoute manually after pushState
  • Route parameter extraction — parsing :id patterns from URLs
  • Click interception — preventing default anchor behavior in SPAs
  • Server-side awareness — History API routing requires server fallback to index.html

Common Mistakes

  • Forgetting that pushState does NOT trigger popstate (only Back/Forward does)
  • Not intercepting anchor clicks (causes full page reloads)
  • Not URL-decoding route parameters
  • Matching routes in insertion order without priority ranking
  • Not handling query parameters
  • Forgetting the server must serve index.html for all routes in History API mode

Red Flags

  • Cannot explain the difference between hash and History API routing
  • Doesn't know what pushState or popstate do
  • Cannot implement basic route parameter extraction
  • No awareness of how React Router connects to these primitives
  • Doesn't mention server-side fallback requirement for History API