Regex Route Matching in Vanilla JS
By the end of this guide you will be able to compile a small table of route patterns into anchored regular expressions, extract path parameters through named capture groups, and dispatch a sensible fallback when nothing matches — all without pulling in a routing library.
← Back to Route Matching Algorithms
Prerequisites
Core Concept
A regex route matcher turns each human-friendly pattern — "/users/:id" — into a single anchored regular expression that either matches the current pathname or it does not. The decisive design choice is strict segment isolation: every dynamic placeholder compiles to [^/]+ (one or more non-slash characters) rather than the seductive but greedy .*, so a parameter can never swallow a slash, a query delimiter, or an adjacent segment. Naming each capture group after its placeholder lets you read parameters straight out of match.groups, with no positional bookkeeping.
The second half of the concept is determinism. Patterns are compiled once, ahead of time, and stored alongside their original placeholder names; matching then becomes a cheap regex.exec per route until one hits or the list is exhausted and a fallback fires. Because the matcher only ever inspects location.pathname, the query string is stripped before any comparison — keeping this layer cleanly separate from the History API & State Management concerns that decide when to re-run a match.
Implementation
// TypeScript 5.x — framework-agnostic, no dependencies
// Compiles ":param" placeholders into named capture groups using [^/]+.
interface CompiledRoute<H> {
readonly source: string; // the original pattern, kept for diagnostics
readonly regex: RegExp; // anchored, with named groups
readonly handler: H;
}
// Characters that are meaningful to RegExp must be escaped in the *static*
// parts of the pattern so a literal "/v1.0/" cannot act as a wildcard.
const escapeLiteral = (segment: string): string =>
segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
function compile<H>(pattern: string, handler: H): CompiledRoute<H> {
// Replace each ":name" with a named group; escape everything around it.
const body = pattern.replace(
/:([A-Za-z_][A-Za-z0-9_]*)|([^:]+)/g,
(_full, name?: string, literal?: string) =>
name ? `(?<${name}>[^/]+)` : escapeLiteral(literal as string),
);
// Allow an optional trailing slash, then anchor both ends so a route
// matches the whole path and never a mere prefix.
return { source: pattern, regex: new RegExp(`^${body}/?$`), handler };
}
class RegexRouter<H> {
private readonly routes: CompiledRoute<H>[] = [];
add(pattern: string, handler: H): this {
this.routes.push(compile(pattern, handler)); // compile once, at setup
return this;
}
match(rawPath: string): { handler: H; params: Record<string, string> } | null {
const pathname = rawPath.split("?")[0]; // discard the query string first
for (const route of this.routes) {
const found = route.regex.exec(pathname);
if (found) {
// groups is undefined for static routes; default to an empty object.
return { handler: route.handler, params: { ...found.groups } };
}
}
return null; // signal "no match" so the caller can run a fallback
}
}
Wiring the matcher to navigation keeps the regex layer ignorant of the History API: it receives a string and returns a result. The fallback path is where you connect to your chosen Fallback Routing Strategies.
// TypeScript 5.x — framework-agnostic, no dependencies
const router = new RegexRouter<(p: Record<string, string>) => void>()
.add("/users/:id", (p) => console.log("user", p.id))
.add("/posts/:slug/comments/:commentId", (p) => console.log(p.slug, p.commentId));
function render(path: string): void {
const hit = router.match(path);
if (hit) hit.handler(hit.params);
else console.warn("no route — render the fallback view"); // 404 handling
}
// Re-run on real back/forward navigation only; pushState does not fire this.
window.addEventListener("popstate", () => render(location.pathname));
render(location.pathname); // resolve the initial URL once on load
Verification
Drop the following into the DevTools console after loading the module. It asserts that a parameter never crosses a slash and that the query string is ignored, which are the two failure modes naive .* matchers hit.
// TypeScript 5.x — framework-agnostic, run in DevTools or a test runner
const r = new RegexRouter<string>()
.add("/users/:id", "user")
.add("/files/:name", "file");
console.assert(r.match("/users/42")?.params.id === "42", "captures id");
console.assert(r.match("/users/42?tab=x")?.params.id === "42", "ignores query");
console.assert(r.match("/users/42/extra") === null, "no slash crossing");
console.assert(r.match("/users/") === null, "empty segment rejected");
console.log("all route assertions passed");
If every assertion stays silent, the matcher is isolating segments correctly. To confirm there is no per-navigation compilation cost, wrap a few hundred r.match(...) calls in performance.now() brackets; with pre-compiled patterns the total should sit comfortably in the sub-millisecond range.
Gotchas
- Empty captures.
[^/]+requires at least one character, so/users/will not match/users/:id— usually correct, but if you want the placeholder to be skippable you need the techniques in Handling Optional Dynamic Segments in Routing rather than a quick*swap. - Route order matters. The first matching pattern wins, so register specific routes before broad ones;
/users/memust precede/users/:idor the literal will be captured as an id. - Duplicate group names throw. Two
:idplaceholders in one pattern raise aSyntaxErrorat compile time. Give each segment a distinct name (:userId,:postId). - Trailing-slash policy. The
/?$suffix treats/users/42and/users/42/as equivalent. If you need them to differ for canonicalisation, drop the optional slash and normalise the URL before matching instead.
FAQ
Why use named capture groups instead of positional matches? Named groups read parameters by meaning (match.groups.id) rather than by index, so reordering or inserting segments in a pattern never silently shifts which value lands in which variable. The matcher also needs no separate list of placeholder names, because the regex itself carries them.
Why prefer [^/]+ over .* for dynamic segments? .* is greedy and matches slashes and the ? query delimiter, so it happily consumes the rest of the URL and breaks parameter extraction. [^/]+ stops at the next slash, keeping each segment isolated and the match deterministic.
Do I need to strip the query string before matching? Yes — split on ? first. Anchoring with $ means a pattern like ^/users/(?<id>[^/]+)/?$ would otherwise fail against /users/42?tab=x, because the trailing ?tab=x is part of the string being tested.
Does the popstate listener fire when I call pushState? No — popstate only fires on back/forward navigation or history.go(), never on programmatic pushState/replaceState. Call your render function manually after those, and lean on the History API & State Management primitives to keep state in sync.
How costly is regex matching for a large route table? Negligible when patterns are compiled once at setup and reused. The per-navigation work is a short loop of regex.exec calls over already-optimised expressions; the constructor cost — the only expensive part — happens during add, outside the navigation path.
Related
- Route Matching Algorithms — the parent overview of how routers turn URLs into handlers.
- Dynamic Route Segments — how placeholders like
:idare defined and mapped to captured values. - Handling Optional Dynamic Segments in Routing — making a segment skippable without resorting to greedy wildcards.