Build Your Own Router
A client-side router watches the URL, matches it against patterns, and calls a handler. Build one in 110 lines — params, wildcards, back/forward — and see the four decisions every router makes for you.
Build Your Own Router
Every React Router tutorial eventually shows you <BrowserRouter> and <Route path="/users/:id"> and leaves you to assume there's some black magic underneath. There isn't. A client-side router is one of those things that feels complicated until you actually write one — at which point it clicks instantly and you wonder why it ever seemed mysterious.
Here's the short version of what a router does: it watches the browser URL, matches it against a list of patterns, and calls a function when there's a match. That's it. Everything else is bookkeeping.
We'll build one in about 110 lines of vanilla JavaScript that handles route params and wildcards, intercepts link clicks without breaking cmd-click, and respects the browser's back/forward buttons.
How a Router Actually Works
The browser gives you two primitives:
history.pushState(null, '', '/some/path')— changes the URL bar without reloading the page- The
popstateevent — fires when the user hits back or forward
A router sits on top of these. It intercepts navigation (either from link clicks or programmatic calls), pushes a new history entry, then figures out which handler to call.
The key insight is that pushState does not fire popstate. That event only fires for browser-initiated navigation (back/forward). So after calling pushState, you have to manually trigger resolution — you're calling resolve() yourself.
Step 1: The Router Skeleton
Start with the class shape. Routes are stored as an array of objects; we resolve from the top down, so order matters (just like Express).
Create the Router class with route registration
// router.js
class Router {
#routes = [];
#notFound = null;
// The full location we last resolved: pathname + search + hash. Not just the
// pathname — see navigate() below.
#currentUrl = null;
constructor() {
window.addEventListener('popstate', () => this.#resolve());
// One delegated listener for every [data-link] on the page, now and later
document.addEventListener('click', (e) => this.#onClick(e));
}
// Register a route — returns `this` for chaining
on(path, handler) {
this.#routes.push({
path,
pattern: this.#pathToPattern(path),
handler,
});
return this;
}
// Register a catch-all for unmatched routes
notFound(handler) {
this.#notFound = handler;
return this;
}
// Programmatic navigation
navigate(path) {
// Normalise before comparing: the caller may pass '/search?q=a', a bare
// '/about', or a relative './x', and we need all three in the same shape as
// what #resolve() recorded.
const url = new URL(path, window.location.href);
const target = url.pathname + url.search + url.hash;
if (target === this.#currentUrl) return; // genuinely already there
history.pushState(null, '', target);
this.#resolve();
}
// Call this once to kick off routing for the current URL
start() {
this.#resolve();
return this;
}
#onClick(e) {
// filled in step 1b
}
#pathToPattern(path) {
// filled in step 2
}
#resolve() {
// filled in step 3
}
}Step 2: Intercepting Clicks Without Breaking the Browser
The click handler is four lines of intent and about six lines of "don't break things the browser already does well". Here it is in full:
Intercept link clicks — and know when not to
#onClick(e) {
// Someone else already handled this, or it isn't a plain left click.
if (e.defaultPrevented || e.button !== 0) return;
// cmd/ctrl = new tab, shift = new window, alt = download. All the user's call.
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
// e.target is an EventTarget. It is usually an element, but a click
// dispatched at `document` is not, and `document.closest` is undefined.
const start = e.target instanceof Element ? e.target : null;
const link = start?.closest('[data-link]');
if (!link) return;
const href = link.getAttribute('href');
if (href === null) return;
if (link.hasAttribute('download') || link.getAttribute('target') === '_blank') return;
// Only intercept same-origin URLs — pushState throws a SecurityError on
// anything else, and it would throw *after* preventDefault, leaving a
// link that does nothing at all.
const url = new URL(href, window.location.href);
if (url.origin !== window.location.origin) return;
e.preventDefault();
this.navigate(url.pathname + url.search + url.hash);
}closest('[data-link]') rather than e.target is what makes <a data-link href="/about"><span>About</span></a> work — the click lands on the span, and closest walks up to the anchor.
Everything else in that function is a guard, and each one maps to a specific way a naive interceptor annoys users. Calling preventDefault() unconditionally means cmd-click no longer opens a new tab, middle-click no longer opens a background tab, download links stop downloading, target="_blank" stops working, and a data-link pointing at an external URL becomes a link that visibly does nothing — preventDefault runs, then pushState throws SecurityError: pushState() cannot update history to the URL https://example.com/x. All five are the kind of bug that gets reported as "the site feels broken" rather than as a stack trace.
Bail out on e.defaultPrevented first. It lets any other click handler on the page opt a link out of routing by calling preventDefault() itself, without your router needing to know about it.
Step 3: Path-to-Pattern Matching
This is the fun part. We need to turn a path like /users/:id/posts/:postId into something we can match against /users/42/posts/7 and get back { id: '42', postId: '7' }.
The approach: convert each :paramName segment into a regex capture group, and record the param names in order so we can zip them back onto the captured values.
Implement path-to-regex conversion and param extraction
#pathToPattern(path) {
const paramNames = [];
const regexString = path
// 1. Neutralise every regex metacharacter in the literal parts.
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
// 2. Re-introduce :name as a capture group matching one URL segment.
.replace(/:([A-Za-z_$][\w$]*)/g, (_, name) => {
paramNames.push(name);
return '([^/]+)';
})
// 3. Re-introduce * (now escaped to \*) as a catch-all.
.replace(/\\\*/g, () => {
paramNames.push('wildcard');
return '(.*)';
});
return {
// \/?$ allows an optional trailing slash
regex: new RegExp(`^${regexString}\\/?$`),
paramNames,
};
}
// decodeURIComponent throws URIError on a malformed escape sequence, and the
// URL bar is not a trustworthy source of well-formed escapes. Fall back to the
// raw segment: a param the handler can't decode is still better than a router
// that stops working.
#safeDecode(segment) {
try {
return decodeURIComponent(segment);
} catch {
return segment;
}
}
#extractParams(pattern, path) {
const match = path.match(pattern.regex);
if (!match) return null; // no match
// match[0] is the full match, match[1..n] are the capture groups
return pattern.paramNames.reduce((params, name, i) => {
params[name] = this.#safeDecode(match[i + 1]);
return params;
}, {});
}The three-step replace looks fussy and each step is load-bearing.
Escape first. A route path is a string the developer wrote, but it goes straight into new RegExp, and most punctuation means something to a regex engine. Register /a.b without escaping and the . is a wildcard — the route matches /aXb. Register /c++ and you don't get a wrong match, you get a crash at registration time: SyntaxError: Invalid regular expression: /^/c++\/?$/: Nothing to repeat. Escaping the metacharacters first means literal path characters stay literal.
Then be strict about param names. The tempting pattern is /:([^/]+)/g — "a colon followed by anything that isn't a slash". It over-reaches: /files/:name.json captures the param name as name.json, so the handler receives { 'name.json': 'report.json' } and params.name is undefined. Restricting names to identifier characters ([A-Za-z_$][\w$]*) stops the name at the dot and leaves .json as a literal part of the pattern, which is what you meant.
Then the wildcard. After step 1 a literal * has become \*, so step 3 looks for that and swaps in (.*), captured as params.wildcard. /files/* matches /files/a/b/c with wildcard: 'a/b/c', and a bare * matches everything.
Three smaller things:
[^/]+matches one or more characters that aren't a forward slash — exactly one URL segment. This is what stops/users/:idfrom matching/users/42/extra.decodeURIComponenthandles encoded characters in URLs. A username likejörgbecomesj%C3%B6rgin the URL; we decode it back before passing to the handler.- Returning
null(not{}) from#extractParamson no-match lets#resolvedistinguish "matched with no params" from "didn't match".
decodeURIComponent throws URIError: URI malformed on a truncated escape — %E0%A4%A is three bytes short of a valid sequence, and there is nothing stopping anyone from putting that in a link. Unwrapped, that throw has two failure modes and the second one is the bad one:
- via
navigate()it propagates to your call site, where you could at least catch it; - via
popstateit propagates out of an event listener.history.back()does not throw — it queues the event and returns. So there is no call stack for your code to wrap. In jsdom the error surfaces asUncaught [URIError: URI malformed]; in a browser it goes towindow.onerror. Either way the route handler never runs, the URL bar has already changed, and the app is now showing the previous page's DOM at the new URL with no way to recover except a reload.
#safeDecode returning the raw segment is the boring correct answer. The handler gets '%E0%A4%A' instead of a decoded string — visibly odd, trivially debuggable, and the router keeps working.
%2F inside a param produces a param containing /, and that is on purpose. The segment boundary is decided before decoding: the browser leaves /users/a%2Fb percent-encoded in location.pathname, [^/]+ sees a%2Fb with no slash in it and matches, and then we decode — so params.name === 'a/b', a value with a / in it that came out of a "one segment only" capture. A real /users/a/b does not match at all and falls through to notFound.
That is the same decision Express and React Router make, and it is worth knowing rather than discovering: a route param is not guaranteed to be a single path segment. If you concatenate one into a filesystem path, a URL, or another route, %2F is a path-traversal primitive. Validate params you're going to reuse structurally (if (params.name.includes('/')) return notFound()), or match on the raw pre-decode segment when the distinction matters.
For static paths like /about, the regex compiles to ^/about\/?$. No capture groups, paramNames is empty, and #extractParams returns {} on match.
Step 4: Resolving the Current URL
With pattern matching in place, #resolve just walks the route list and calls the first handler that matches.
Implement resolve — the heart of the router
#resolve() {
const { pathname, search, hash } = window.location;
const path = pathname;
// Record the *whole* location, so navigate() can tell '/search' apart from
// '/search?q=a'. Matching still happens on the pathname alone.
this.#currentUrl = pathname + search + hash;
for (const route of this.#routes) {
const params = this.#extractParams(route.pattern, path);
if (params !== null) {
route.handler({ path, params, search, hash });
return; // first match wins, like Express
}
}
// Nothing matched
if (this.#notFound) {
this.#notFound({ path, params: {}, search, hash });
}
}That's the complete router. The whole thing is first-match-wins.
The dedupe check in navigate() and the value #resolve() records have to describe the same thing, and in an earlier version of this router they didn't: #resolve stored window.location.pathname while navigate compared it against path + search. Every navigation that only changed the query string still went through — but the reverse didn't. Sitting on /search?q=b and calling navigate('/search') compared '/search' against the stored '/search', matched, and returned. No pushState, no re-resolve, no error: clearing a filter was a silent no-op. Measured on the four-call sequence ?q=a, ?q=b, bare, ?q=c, the handler fired three times instead of four.
Two lessons that generalise past routers. A cache key that is narrower than the thing it identifies will collide, and the collision looks like nothing happening. And "is this a no-op?" checks are worth the extra assertion precisely because a wrong answer produces no output to notice.
First-match-wins means registration order, not specificity. Register /users/:id before /users/new and navigating to /users/new calls the :id handler with params.id === 'new' — no warning, no ambiguity error, and a bug that only shows up for one URL. Express behaves the same way; React Router does not, because it ranks routes by specificity so a static segment beats a dynamic one regardless of declaration order.
If you keep the loop, the rule is: static routes, then dynamic, then wildcards. If you'd rather not rely on the rule, sort #routes in start() — score each segment (static 3, dynamic 2, wildcard 1), sort descending, and order stops mattering.
Step 5: Putting It Together — The Full Class
Here's the complete implementation in one place:
Complete Router implementation
// router.js
class Router {
#routes = [];
#notFound = null;
#currentUrl = null; // pathname + search + hash of the last resolve
constructor() {
window.addEventListener('popstate', () => this.#resolve());
document.addEventListener('click', (e) => this.#onClick(e));
}
on(path, handler) {
this.#routes.push({
path,
pattern: this.#pathToPattern(path),
handler,
});
return this;
}
notFound(handler) {
this.#notFound = handler;
return this;
}
navigate(path) {
const url = new URL(path, window.location.href);
const target = url.pathname + url.search + url.hash;
if (target === this.#currentUrl) return;
history.pushState(null, '', target);
this.#resolve();
}
start() {
this.#resolve();
return this;
}
#onClick(e) {
// Let the browser handle new-tab / new-window / download clicks.
if (e.defaultPrevented || e.button !== 0) return;
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
// e.target is an EventTarget, not necessarily an Element.
const start = e.target instanceof Element ? e.target : null;
const link = start?.closest('[data-link]');
if (!link) return;
const href = link.getAttribute('href');
if (href === null) return;
if (link.hasAttribute('download') || link.getAttribute('target') === '_blank') return;
// Only intercept same-origin URLs — pushState throws on anything else.
const url = new URL(href, window.location.href);
if (url.origin !== window.location.origin) return;
e.preventDefault();
this.navigate(url.pathname + url.search + url.hash);
}
#pathToPattern(path) {
const paramNames = [];
// Escape every regex metacharacter, then re-introduce the two we define:
// :name -> one URL segment, captured
// * -> the rest of the path, captured as `wildcard`
const regexString = path
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
.replace(/:([A-Za-z_$][\w$]*)/g, (_, name) => {
paramNames.push(name);
return '([^/]+)';
})
.replace(/\\\*/g, () => {
paramNames.push('wildcard');
return '(.*)';
});
return {
// \/?$ allows an optional trailing slash
regex: new RegExp(`^${regexString}\\/?$`),
paramNames,
};
}
// A malformed escape must not take the router down — especially not on
// popstate, where the throw is uncatchable by the caller.
#safeDecode(segment) {
try { return decodeURIComponent(segment); } catch { return segment; }
}
#extractParams(pattern, path) {
const match = path.match(pattern.regex);
if (!match) return null;
return pattern.paramNames.reduce((params, name, i) => {
params[name] = this.#safeDecode(match[i + 1]);
return params;
}, {});
}
#resolve() {
const { pathname, search, hash } = window.location;
const path = pathname;
this.#currentUrl = pathname + search + hash;
for (const route of this.#routes) {
const params = this.#extractParams(route.pattern, path);
if (params !== null) {
route.handler({ path, params, search, hash });
return;
}
}
if (this.#notFound) {
this.#notFound({ path, params: {}, search, hash });
}
}
}Step 6: Build a Demo App
Let's wire it up to a real (if minimal) HTML page. Create index.html:
Create the demo app
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Router Demo</title>
<style>
body { font-family: sans-serif; max-width: 600px; margin: 2rem auto; }
nav { margin-bottom: 1.5rem; }
nav a { margin-right: 1rem; }
</style>
</head>
<body>
<nav>
<a href="/" data-link>Home</a>
<a href="/users" data-link>Users</a>
<a href="/about" data-link>About</a>
</nav>
<div id="app"></div>
<script src="router.js"></script>
<script src="app.js"></script>
</body>
</html>Now app.js — each route handler simply sets innerHTML. In a real app you'd call a render function or a framework's reconciler.
// app.js
const app = document.getElementById('app');
// Route params come from the URL, and every handler below writes innerHTML.
// Anything from the URL goes through this first. See the callout after this step.
const escapeHtml = (val) =>
String(val).replace(/[&<>"']/g, (c) => (
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
));
// Fake data stand-in
const users = [
{ username: 'alice', name: 'Alice Chen', role: 'admin' },
{ username: 'bob', name: 'Bob Patel', role: 'editor' },
{ username: 'carol', name: 'Carol Kim', role: 'viewer' },
];
const router = new Router()
.on('/', () => {
app.innerHTML = `
<h1>Dashboard</h1>
<p>Welcome. <a href="/users" data-link>View all users →</a></p>
`;
})
.on('/users', () => {
const items = users
.map(u => `<li><a href="/users/${u.username}" data-link>${u.name}</a></li>`)
.join('');
app.innerHTML = `<h1>Users</h1><ul>${items}</ul>`;
})
.on('/users/:username', ({ params }) => {
const user = users.find(u => u.username === params.username);
if (!user) {
app.innerHTML =
`<p>User "${escapeHtml(params.username)}" not found. <a href="/users" data-link>Back</a></p>`;
return;
}
app.innerHTML = `
<h1>${user.name}</h1>
<p>Username: <code>${user.username}</code></p>
<p>Role: <strong>${user.role}</strong></p>
<a href="/users" data-link>← All users</a>
`;
})
.on('/about', () => {
app.innerHTML = `<h1>About</h1><p>Built with a 110-line router.</p>`;
})
.notFound(({ path }) => {
app.innerHTML = `
<h1>404</h1>
<p><code>${escapeHtml(path)}</code> doesn't exist.</p>
<a href="/" data-link>Go home</a>
`;
})
.start();Open index.html in a browser (serve it with npx serve . or Live Server — don't open the file directly, file:// URLs don't support pushState properly). Click around, hit the back button, paste a direct URL. It all works.
escapeHtml around params.username is not decoration. A route param is attacker-controlled input that your router hands you already decodeURIComponent'd, and innerHTML compiles whatever string you give it into DOM. Without the escape, visiting
/users/%3Cimg%20src%3Dx%20onerror%3Dalert(1)%3Ematches /users/:username, finds no such user, and inserts a live <img onerror> into the page — reflected XSS from nothing but a URL someone can put in a link. I checked this against the router above in jsdom: the resulting DOM contains a real <img> element with an onerror attribute.
Interestingly the 404 handler is not exploitable through the same trick, because window.location.pathname stays percent-encoded — the browser hands you /zzz%3Cimg..., which is inert in HTML. That difference is exactly why "escape at the point of output" beats "remember which values are already safe".
Direct URL entry (typing /users/alice in the address bar and hitting Enter) still triggers a full page load. The browser asks the server for /users/alice. If the server returns 404, your app never loads. In production you need your server (nginx, Caddy, Vite, Express) configured to serve index.html for all routes. With Vite dev server this is automatic; with nginx it's try_files $uri /index.html.
Step 7: Query String Support
Route params handle /users/:id, but what about /search?q=foo&page=2? Add a tiny helper:
Add query string parsing
// Add this as a method on Router, or as a standalone utility
function parseQuery(search = window.location.search) {
// URLSearchParams handles all the edge cases: repeated keys, encoding, etc.
const params = new URLSearchParams(search);
// Object.create(null), not {} — see below.
const result = Object.create(null);
for (const [key, value] of params) {
// Handle repeated keys: ?tag=js&tag=node -> { tag: ['js', 'node'] }
if (key in result) {
result[key] = [].concat(result[key], value);
} else {
result[key] = value;
}
}
return result;
}const result = {} in that function is a real bug, and it's the kind that survives review because the code reads correctly. Query keys come from the URL, which means an attacker picks them, and {} inherits constructor, toString, valueOf, hasOwnProperty and __proto__ from Object.prototype. The key in result test walks the prototype chain, so those keys look like they're already present on the first sighting:
parseQuery('?constructor=1')
// with {}: { constructor: [ [Function: Object], '1' ] }
// with Object.create(null): [Object: null prototype] { constructor: '1' }
parseQuery('?__proto__=1')
// with {}: the object's prototype is replaced by an arrayObject.create(null) has no prototype, so there is nothing to collide with and in means what it looks like it means. The trade: no inherited methods, so String(result) throws and console.log prints it as [Object: null prototype]. That is the correct trade for a bag of keys you don't control. If you need a normal object, keep Object.create(null) internally and use Object.hasOwn(result, key) for the check.
Then in a route handler:
.on('/search', () => {
const { q = '', page = '1' } = parseQuery();
app.innerHTML = `<h1>Search: "${escapeHtml(q)}" — page ${page}</h1>`;
})Note the escapeHtml. q comes from the URL, and innerHTML with an interpolated query parameter is reflected XSS — ?q=<img src=x onerror=alert(1)> executes. Every route handler in this article uses innerHTML for brevity; every one of them that interpolates a param or a query value needs either escaping or textContent.
URLSearchParams is built into every modern browser and Node.js. Don't write your own query parser.
Checking It Against the Browser
The router needs window, history and document, so the cheapest honest test environment is jsdom rather than a fake. npm i -D jsdom, then:
// router.test.mjs
import { JSDOM, VirtualConsole } from 'jsdom';
import fs from 'node:fs';
// console.assert() does not throw in Node — it logs and continues, so a
// harness built on it prints "all passed" no matter what. Throw instead.
const assert = (cond, msg) => { if (!cond) throw new Error('FAILED: ' + msg); };
// Each test gets a clean document: constructing a Router attaches a listener
// to `document`, and nothing detaches it.
//
// The VirtualConsole matters more than it looks. jsdom does NOT propagate an
// exception thrown inside an event listener out of dispatchEvent() — it reports
// it as a `jsdomError` and carries on, exactly like a browser sending it to
// window.onerror. Collect them, or every listener bug in this file is invisible.
function fresh() {
const errors = [];
const virtualConsole = new VirtualConsole();
virtualConsole.on('jsdomError', (e) => errors.push(e.detail ?? e));
const dom = new JSDOM(
`<a href="/users" data-link>Users</a>
<a href="/about" data-link><span id="inner">About</span></a>
<a href="https://elsewhere.test/x" data-link>External</a>`,
{ url: 'https://app.test/', runScripts: 'outside-only', virtualConsole }
);
dom.window.eval(fs.readFileSync('./router.js', 'utf8') + ';globalThis.__Router = Router;');
return { win: dom.window, Router: dom.window.__Router, errors };
}
const leftClick = (win, el, opts = {}) =>
el.dispatchEvent(new win.MouseEvent('click', { bubbles: true, cancelable: true, button: 0, ...opts }));
// --- matching
{
const { win, Router } = fresh();
let seen = null;
const router = new Router()
.on('/about', () => (seen = { route: 'about' }))
.on('/users/:id/posts/:postId', ({ params }) => (seen = params))
.on('/files/*', ({ params }) => (seen = params))
.on('/a.b', () => (seen = { route: 'literal-dot' }))
.notFound(({ path }) => (seen = { notFound: path }))
.start();
router.navigate('/users/42/posts/7');
assert(seen.id === '42' && seen.postId === '7', 'two params extracted');
router.navigate('/users/42/posts/7/');
assert(seen.id === '42', 'trailing slash still matches');
router.navigate('/u/j%C3%B6rg');
assert(seen.notFound === '/u/j%C3%B6rg', 'unmatched path reaches notFound');
router.navigate('/files/deep/nested/file.txt');
assert(seen.wildcard === 'deep/nested/file.txt', 'wildcard captures the rest');
router.navigate('/aXb');
assert(seen.notFound === '/aXb', 'the dot in /a.b is literal, not a regex wildcard');
router.navigate('/users/42');
assert(seen.notFound === '/users/42', ':id does not span segments');
}
// --- malformed percent-escapes must not throw. The popstate case is the one
// that matters: there is no call stack for the app to wrap.
{
const { win, Router, errors } = fresh();
let seen = null;
const router = new Router()
.on('/', () => {})
.on('/users/:name', ({ params }) => (seen = params))
.notFound(() => (seen = 'notFound'))
.start();
router.navigate('/users/%E0%A4%A'); // three bytes short of a valid sequence
assert(seen && seen.name === '%E0%A4%A', 'malformed escape falls back to the raw segment');
// Same URL reached by the back button. This is the case with no call stack.
win.history.pushState(null, '', '/users/%E0%A4%A');
win.history.pushState(null, '', '/users/ok');
seen = null;
win.history.back();
await new Promise((r) => setTimeout(r, 50));
assert(seen && seen.name === '%E0%A4%A', 'popstate onto a malformed URL still resolves');
assert(errors.length === 0, 'popstate must not raise an uncaught error: ' + errors.map((e) => e.message).join());
router.navigate('/users/j%C3%B6rg');
assert(seen.name === 'jörg', 'well-formed escapes are still decoded');
}
// --- %2F inside a param. Documented behaviour, so pin it.
{
const { Router } = fresh();
let seen = null, missed = null;
const router = new Router()
.on('/', () => {})
.on('/users/:name', ({ params }) => (seen = params))
.notFound(({ path }) => (missed = path))
.start();
router.navigate('/users/a%2Fb');
assert(seen.name === 'a/b', '%2F decodes to a slash *inside* a single-segment param');
router.navigate('/users/a/b');
assert(missed === '/users/a/b', 'a real slash does not match a single-segment param');
}
// --- navigate() dedupe compares the whole location, not just the pathname.
{
const { win, Router } = fresh();
const hits = [];
const router = new Router()
.on('/', () => {})
.on('/search', () => hits.push(win.location.pathname + win.location.search))
.start();
router.navigate('/search?q=a');
router.navigate('/search?q=b');
router.navigate('/search'); // clearing the query is a real navigation
router.navigate('/search?q=c');
assert(hits.join(' ') === '/search?q=a /search?q=b /search /search?q=c',
'query-only navigation is not deduped away: ' + hits.join(' '));
const before = hits.length;
router.navigate('/search?q=c'); // this one really is a no-op
assert(hits.length === before, 'navigating to the identical URL is still a no-op');
}
// --- registration order beats specificity. This is a documented sharp edge,
// so pin it: if it ever changes, that is a breaking change.
{
const { Router } = fresh();
let which = null;
const router = new Router()
.on('/users/:id', () => (which = 'dynamic'))
.on('/users/new', () => (which = 'static'))
.start();
router.navigate('/users/new');
assert(which === 'dynamic', 'first match wins, even when a later route is more specific');
}
// --- history
{
const { win, Router } = fresh();
const seen = [];
const router = new Router()
.on('/', () => seen.push('/'))
.on('/a', () => seen.push('/a'))
.on('/b', () => seen.push('/b'))
.start();
router.navigate('/a');
router.navigate('/b');
win.history.back();
await new Promise((r) => setTimeout(r, 50)); // popstate is async
assert(seen.join() === '/,/a,/b,/a', 'back button re-resolves: ' + seen.join());
}
// --- click interception
{
const { win, Router } = fresh();
let hit = null;
new Router().on('/', () => {}).on('/about', () => (hit = 'about')).start();
leftClick(win, win.document.getElementById('inner'));
assert(hit === 'about', 'a click on a child element still routes');
}
for (const modifier of ['metaKey', 'ctrlKey', 'shiftKey']) {
const { win, Router } = fresh();
let hit = false;
new Router().on('/', () => {}).on('/users', () => (hit = true)).start();
const ev = new win.MouseEvent('click', { bubbles: true, cancelable: true, button: 0, [modifier]: true });
win.document.querySelector('a[href="/users"]').dispatchEvent(ev);
assert(!hit && !ev.defaultPrevented, modifier + '+click must be left to the browser');
}
{
const { win, Router } = fresh();
new Router().on('/', () => {}).notFound(() => {}).start();
const ev = new win.MouseEvent('click', { bubbles: true, cancelable: true, button: 0 });
win.document.querySelector('a[href^="https://"]').dispatchEvent(ev);
assert(!ev.defaultPrevented, 'a cross-origin data-link is not ours to intercept');
}
{
const { win, Router, errors } = fresh();
new Router().on('/', () => {}).start();
// e.target is `document` here, which has no .closest(). It must be a real
// MouseEvent: on a plain Event, `e.button` is undefined, the `button !== 0`
// guard returns first, and this test would pass without ever reaching the
// line it exists to protect.
win.document.dispatchEvent(
new win.MouseEvent('click', { bubbles: true, cancelable: true, button: 0 })
);
assert(errors.length === 0,
'a click whose target is `document` must not throw: ' + errors.map((e) => e.message).join());
}
// The canary. If assert() ever stops throwing, everything above is decoration.
let harnessWorks = false;
try { assert(false, 'canary'); } catch { harnessWorks = true; }
assert(harnessWorks, 'assert() does not throw');
console.log('All assertions passed.');node router.test.mjs. The last block is the one worth dwelling on, because it took three tries to make it able to fail at all.
It's there to pin one line: e.target instanceof Element ? e.target : null. Dispatch a click whose target is document and document.closest is undefined, so a router written as e.target.closest('[data-link]') dies with TypeError: e.target.closest is not a function. Two things had to be true before the test could see that:
- The event has to be a
MouseEvent. The first version dispatchednew Event('click'). A plainEventhas nobuttonproperty, soe.button !== 0wasundefined !== 0— true — and the interceptor returned on its first line. InstrumentingElement.prototype.closestand counting calls proves it: zero during that dispatch. The test passed against a broken router and a fixed one alike. - The failure has to be observable. jsdom does not propagate an exception thrown inside a listener out of
dispatchEvent(). It reports ajsdomErrorand continues — the same thing a browser does when it hands the error towindow.onerror. So even with the right event,dispatchEventreturned normally and the block still passed. Hence theVirtualConsoleinfresh()and the expliciterrors.length === 0.
Mutation-tested, all three fixes in this article, by reverting each one and re-running the suite:
| Reverted to | Result |
|---|---|
decodeURIComponent without the try | URIError: URI malformed |
dedupe on pathname only | FAILED: query-only navigation is not deduped away: /search?q=a /search?q=b /search?q=c |
e.target.closest(...) with no instanceof guard | FAILED: a click whose target is \document` must not throw: Uncaught [TypeError: e.target.closest is not a function]` |
The third row is the whole reason the previous two paragraphs exist. Before the VirtualConsole was added, that same mutant left the suite completely green.
What We Skipped
This is a usable router, but real frameworks add a lot on top:
Route guards / middleware. Before calling a handler, check if the user is authenticated. If not, redirect to /login. Implementing this cleanly requires either async handlers (so guards can do await checkAuth()) or a separate beforeEach hook that can abort navigation.
Nested / child routes. React Router's <Outlet> pattern lets a parent route render a layout and child routes fill in a slot. Our router has no concept of route hierarchy — every route is flat.
Scroll restoration. When the user navigates back, they expect to land at the same scroll position they left. history.scrollRestoration = 'manual' and manually tracking scrollY per history entry is the solution.
Hash routing. If you can't configure the server to serve index.html for all paths (GitHub Pages without a 404.html redirect hack, for instance), hash routing using window.location.hash and the hashchange event sidesteps the server requirement entirely. The URL becomes /app#/users/alice instead of /users/alice.
Async route handlers. Route handlers that fetch data before rendering need the router to know they're async so it can show a loading state, handle errors, and cancel in-flight requests if the user navigates away mid-load.
Transition hooks. beforeLeave / afterLeave for cleanup, animations, or prompting "You have unsaved changes — are you sure?"
Route deregistration. Every new Router() attaches a listener to document and to window and nothing ever detaches them. One router per page makes that a non-issue; a router you construct per test or per micro-frontend needs a destroy().
The reason to write this once by hand isn't to replace React Router — it's that the four decisions below stop being framework trivia and become things you have an opinion about:
- Precedence: order or specificity? A loop over an array gives you order, and order is a footgun the day someone adds a route above another one. Specificity ranking costs a sort and removes a class of bug. Know which one your router does before you debug a route that "isn't firing".
- Where does the pattern string get escaped? Any router that builds a
RegExpfrom a developer-supplied path and forgets to escape it will silently mis-match/pricing/v2.0and crash on/c++. It's a one-line fix and an easy thing to check in a dependency. - What does the click interceptor refuse to intercept? Modifier keys, non-left buttons,
download,target, cross-origin. Missing guards here don't throw; they just make the app feel wrong. - Who escapes route params? They arrive decoded, and they're attacker-controlled. If your rendering layer isn't escaping them for you, you are.
Server config is the fifth, and it's the one that will actually page you: try_files $uri /index.html, or every deep link 404s.
Comments (0)
No comments yet. Be the first to share your thoughts!
