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 reloadpopstateevent â fires when user clicks Back/Forward (but NOT onpushState)- Click interception â anchor clicks are intercepted to prevent default navigation
- Server fallback required â the server must return
index.htmlfor 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:"
<BrowserRouter>â creates the History API wrapper and provides it via Context<Routes>/<Route>â declarative route definitions that React Router converts into a route config treeuseNavigate()â callshistory.pushState+ triggers a context update to re-renderuseParams()â reads extracted params from the matched route context<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/42renders bothrenderUsersLayout(with navigation) andrenderUserDetail(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
pushStatedoesn't firepopstateâ you must callhandleRoutemanually afterpushState- Route parameter extraction â parsing
:idpatterns 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
pushStatedoes NOT triggerpopstate(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.htmlfor all routes in History API mode
Red Flags
- Cannot explain the difference between hash and History API routing
- Doesn't know what
pushStateorpopstatedo - 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