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
How each pattern piece compiles to a regular expression Five pattern pieces with their compiled regular expression form: escaped static text, a single-segment capture, an optional segment that also swallows its slash, a catch-all, and the anchors without which the pattern matches substrings. Turning a pattern into a safe regular expression pattern piece compiled form a static segment escape regex metacharacters first literal text :param ([^/]+) one segment, no slashes :param? (?:/([^/]+))? the slash is optional too *rest (.*) everything remaining anchoring ^ … /?$ or the pattern matches substrings
The last row is the one that bites: an unanchored pattern matches inside longer paths and produces silent mismatches.

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.

Five costs of a naive regular expression matcher Five performance and correctness costs of a linear regex matcher: recompiling per navigation, testing every route, order dependence, catastrophic backtracking, and unescaped metacharacters in static segments. Costs a naive regex matcher pays cost mitigation compiling on every navigation recreating RegExp objects per match compile once at registration testing every route O(n) per lookup sort by specificity, exit on first match order-dependent results a reorder changes the winner rank instead of relying on order catastrophic backtracking a pathological pattern hangs avoid nested quantifiers unescaped dots in static text '/v1.0' matches '/v1x0' escape before compiling
Only the second is inherent to the strategy; the other four are implementation details worth fixing regardless of scale.

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/me must precede /users/:id or the literal will be captured as an id.
  • Duplicate group names throw. Two :id placeholders in one pattern raise a SyntaxError at compile time. Give each segment a distinct name (:userId, :postId).
  • Trailing-slash policy. The /?$ suffix treats /users/42 and /users/42/ as equivalent. If you need them to differ for canonicalisation, drop the optional slash and normalise the URL before matching instead.
Five test cases for a regular expression matcher Five assertions a regex-based matcher should satisfy: exact static matching, rejection of extra segments, correct handling of an encoded slash, trailing-slash normalisation, and escaped dots in static segments. Test cases every regex matcher should have input expectation the exact static path /about matches, no params one segment too many /about/extra no match — anchoring works an encoded slash in a param /users/a%2Fb one param, not two segments a trailing slash /about/ matches, after normalisation a dot in a static segment /v1.0/status literal, not any-character
Three of these five fail in a matcher assembled without escaping and anchoring — and all three fail silently.

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.