Using URLPattern for Edge Route Matching

This guide is part of Supported Web APIs in Edge Runtimes. It replaces the pile of pathname.startsWith checks and hand-written regular expressions that every middleware file accumulates with a declarative, ordered rule table built on URLPattern.

Routing is the part of middleware that looks trivial and is not. A path check that reads correctly in review still lets /admin-preview through a guard meant for /admin, still fails to extract a tenant from a subdomain, and still gets slower every time someone adds a rule. URLPattern exists because URL matching has enough structure — protocol, hostname, pathname, search, hash — that hand-rolling it is a category error.

The problem

The middleware starts with one check. Six months later it looks like this:

const p = new URL(request.url).pathname;
if (p.startsWith('/admin')) return requireAdmin(request);
if (p.match(/^\/api\/v\d+\/users\/([^/]+)$/)) return rateLimit(request);
if (p.startsWith('/api')) return addCors(request);
if (p === '/login' || p === '/signup') return noStore(request);

Three separate bugs are already present. startsWith('/admin') matches /admin-preview, a public marketing page, and sends it through the admin guard. The regex extracts a user id into match[1], which is positional and silently shifts the day someone adds a group. And the /api rule below it is now unreachable for versioned user routes in a way that depends entirely on line order — which is correct here and will not be after the next merge.

Adding hostname matching for a multi-tenant subdomain makes it worse, because the hostname lives on a different property and now every rule needs two conditions kept in sync.

Root cause: URLs are structured, string matching is not

A URL is a parsed object with five independently meaningful components. String prefix checks flatten that structure into one dimension and throw away the segment boundaries that carry the meaning. /admin and /admin-preview share a prefix but not a first path segment, and only the second fact is the one you care about.

Regular expressions restore the boundaries but at a cost. They are positional rather than named, they are easy to write with catastrophic backtracking, and they cannot express “this hostname and this pathname and this query parameter” without being manually assembled from parts. Worse, at the edge a regex compiled inside the request handler is recompiled on every invocation, and CPU time is the metered resource — see avoiding CPU time limit errors in Cloudflare Workers for what that costs when it goes wrong.

URLPattern is the standard answer: a matcher that understands URL structure, uses the familiar :name and * syntax, returns named groups, and compiles once into a reusable object.

URLPattern component anatomy A URL is broken into protocol, hostname, pathname and search components. Each component has its own pattern string, and named groups and wildcards inside the pathname produce entries in the result groups object. incoming URL https: acme.app.example.com /api/v2/users/u_42 ?expand=orders pattern component protocol :tenant.app.example.com /api/:version/users/:id search match.hostname.groups.tenant = "acme" match.pathname.groups.version = "v2" match.pathname.groups.id = "u_42"
Groups are namespaced per component, so a hostname group and a pathname group of the same name never collide.

Step 1 — Compile patterns once at module scope

A URLPattern constructor parses the pattern strings and builds the underlying matchers. That work is identical on every request, so it belongs outside the handler — the same discipline that applies to key imports and JWKS caching.

// lib/routes.ts
export type RouteName =
  | 'admin' | 'api-user' | 'api-any' | 'auth-page' | 'tenant-app' | 'asset';

export interface Rule {
  name: RouteName;
  pattern: URLPattern;
}

// Module scope: constructed once per isolate, reused by every request.
export const rules: Rule[] = [
  { name: 'asset',      pattern: new URLPattern({ pathname: '/_next/*' }) },
  { name: 'admin',      pattern: new URLPattern({ pathname: '/admin{/*}?' }) },
  { name: 'api-user',   pattern: new URLPattern({ pathname: '/api/:version/users/:id' }) },
  { name: 'api-any',    pattern: new URLPattern({ pathname: '/api/*' }) },
  { name: 'auth-page',  pattern: new URLPattern({ pathname: '/(login|signup|reset)' }) },
  {
    name: 'tenant-app',
    pattern: new URLPattern({
      protocol: 'https',
      hostname: ':tenant.app.example.com',
      pathname: '/*',
    }),
  },
];

Two syntax notes. '/admin{/*}?' matches /admin and everything beneath it but not /admin-preview, because the group requires a / before the wildcard — this is the exact bug the startsWith version had. And any component you omit from the init object defaults to a wildcard, so the asset rule matches that pathname on any host and any protocol. That default is convenient and occasionally surprising: if you specify only pathname, you have not constrained the hostname at all.

Step 2 — Resolve with an ordered, first-match-wins router

Ordering is the whole design. Put the most specific rules first and let the first match win, so /api/v2/users/u_42 resolves to api-user rather than the broader api-any.

// lib/router.ts
import { rules, type RouteName } from './routes';

export interface Route {
  name: RouteName;
  params: Record<string, string>;
}

export function resolve(url: string): Route | null {
  for (const rule of rules) {
    const match = rule.pattern.exec(url);
    if (!match) continue;
    return {
      name: rule.name,
      // Merge named groups from every component; skip the unnamed numeric ones.
      params: {
        ...named(match.hostname.groups),
        ...named(match.pathname.groups),
        ...named(match.search.groups),
      },
    };
  }
  return null;
}

function named(groups: Record<string, string | undefined>): Record<string, string> {
  const out: Record<string, string> = {};
  for (const [key, value] of Object.entries(groups)) {
    // Wildcards produce numeric keys like "0"; named groups produce identifiers.
    if (value !== undefined && !/^\d+$/.test(key)) out[key] = value;
  }
  return out;
}

exec returns null on no match and a result object otherwise, with a groups record per component. Unmatched optional groups come back as undefined rather than being absent, which is why the filter checks the value and not just the key. Wildcards contribute numerically keyed entries — 0, 1 and so on — and dropping them keeps the params object to things you deliberately named.

If you only need a yes-or-no answer, pattern.test(url) skips building the result object and is marginally cheaper.

First-match-wins rule evaluation A request URL is tested against each rule in declaration order. The first three rules fail, the fourth matches and returns immediately, and the remaining rules are never evaluated. /api/v2/users/u_42 1. asset /_next/* no match 2. admin /admin{/*}? no match 3. api-user /api/:version/users/:id match, return 4. api-any /api/* never evaluated 5. auth-page /(login|signup) never evaluated name: "api-user" params.version = "v2" params.id = "u_42" 3 tests, not 5 Order is behaviour, not style: move rule 4 above rule 3 and the named groups disappear.
Because evaluation short-circuits, putting your highest-traffic rule early is a real optimisation as well as a correctness decision.

Step 3 — Match on hostname and search, not just pathname

This is where URLPattern earns its place over regex. Multi-tenant routing keyed on a subdomain becomes one declarative rule that extracts the tenant as a named group, and a query-parameter condition becomes part of the same match rather than a separate if.

// lib/tenant.ts
const tenantApi = new URLPattern({
  protocol: 'https',
  hostname: ':tenant.app.example.com',
  pathname: '/api/:version/*',
  // A search pattern matches the raw query string, so keep it loose.
  search: '*',
});

// Exact query matching: only requests that explicitly ask for the beta surface.
const betaOptIn = new URLPattern({
  pathname: '/dashboard{/*}?',
  search: 'flag=beta',
});

export function tenantOf(url: string): string | null {
  return tenantApi.exec(url)?.hostname.groups.tenant ?? null;
}

export function isBetaOptIn(url: string): boolean {
  return betaOptIn.test(url);
}

The search component is a trap worth naming. It matches the query string as a string, not as a parsed parameter set, so search: 'flag=beta' does not match ?utm=x&flag=beta — order and additional parameters matter. For anything beyond the simplest case, match with search: '*' and then read new URL(url).searchParams, which handles ordering and repetition properly. The same reasoning drives cache-key hygiene in normalizing query parameters in edge cache keys.

Hostname patterns have their own subtlety: the separator is ., so :tenant.app.example.com matches exactly one label. A tenant with a dotted subdomain will not match, and *.app.example.com is what you want if you need to span labels.

Step 4 — Provide a fallback where URLPattern is missing

URLPattern is native on Cloudflare Workers and Deno, and Netlify Edge Functions inherit it from Deno. Vercel’s Edge Runtime and Node’s --experimental surface have been less consistent, and older Safari and Firefox builds still lack it. A twenty-line fallback removes the question entirely.

Provider URLPattern availability Notes
Cloudflare Workers Native, no flag Available on the global scope in workerd; matches the WHATWG specification.
Vercel Edge Middleware Not guaranteed on the global Ship the fallback or a polyfill import; next/server route matchers are path-string only.
Netlify Edge Functions Native via Deno Deno has shipped it unflagged for several major versions; also exposed to the config.path matcher as a path string.
Node 20+ (Vitest, local dev) Behind a flag or absent Import a polyfill in the test setup file so the suite exercises the same code path.
// lib/urlpattern-fallback.ts
// Compiles the subset of pattern syntax this codebase uses: :named, * and
// literal segments in the pathname. Enough to keep the router working.
type Compiled = { re: RegExp; keys: string[] };

function compile(pathname: string): Compiled {
  const keys: string[] = [];
  const source = pathname
    .replace(/[.+^${}()|[\]\\]/g, '\\$&')   // escape regex metacharacters
    .replace(/\{\/\*\}\?/g, '(?:/.*)?')      // optional trailing subtree
    .replace(/:([A-Za-z_]\w*)/g, (_, key: string) => { keys.push(key); return '([^/]+)'; })
    .replace(/\*/g, '.*');
  return { re: new RegExp(`^${source}$`), keys };
}

export class PathPattern {
  private readonly compiled: Compiled;
  constructor(pathname: string) { this.compiled = compile(pathname); }

  exec(url: string): { pathname: { groups: Record<string, string> } } | null {
    const { pathname } = new URL(url);
    const m = this.compiled.re.exec(pathname);
    if (!m) return null;
    const groups: Record<string, string> = {};
    this.compiled.keys.forEach((key, i) => { groups[key] = m[i + 1]; });
    return { pathname: { groups } };
  }

  test(url: string): boolean { return this.exec(url) !== null; }
}

// One line decides which implementation the router uses.
export const Pattern =
  typeof URLPattern === 'function'
    ? (p: string) => new URLPattern({ pathname: p })
    : (p: string) => new PathPattern(p);

The fallback deliberately does not attempt hostname or search matching. Supporting a subset you actually use is honest; a partial reimplementation that silently diverges on syntax you forgot about is worse than no fallback at all. Keep the pattern strings in the rule table within that subset and the two paths stay interchangeable.

Native versus fallback selection At module load the code checks whether URLPattern exists. Cloudflare and Netlify take the native branch, while environments without it compile a small regex-based pattern instead. Both branches expose the same exec and test methods. module load typeof URLPattern native URLPattern Cloudflare Workers, Netlify full component support PathPattern fallback Vercel Edge, Node under test pathname subset only one router exec() and test() present absent
The branch is taken once at module load, so the per-request path has no capability check in it at all.

Step 5 — Wire the router into middleware and scope the matcher

// middleware.ts
import { resolve } from './lib/router';

export default async function middleware(request: Request): Promise<Response> {
  const route = resolve(request.url);

  switch (route?.name) {
    case 'asset':
      return fetch(request);                       // no work on static assets

    case 'admin':
      return requireAdmin(request);

    case 'api-user': {
      const headers = new Headers(request.headers);
      headers.set('x-route', 'api-user');
      headers.set('x-user-id', route.params.id);   // named, not match[1]
      headers.set('x-api-version', route.params.version);
      return fetch(new Request(request, { headers }));
    }

    case 'api-any':
      return withCors(await fetch(request));

    case 'auth-page':
      return noStore(await fetch(request));

    default:
      return fetch(request);
  }
}

export const config = {
  runtime: 'edge',
  // Coarse platform-level filter; the pattern table does the fine-grained work.
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

The platform matcher and the pattern table serve different purposes and you want both. The matcher decides whether your middleware is invoked at all, which on a metered platform is a billing decision. The pattern table decides what happens once it is, and that is where the named groups and the ordering live. Matcher precedence itself is covered in controlling matcher order in Next.js middleware.

Local versus production divergence

Behaviour Local dev Edge production
URLPattern global Absent in plain Node; present in Deno and workerd Present on Cloudflare and Netlify, uncertain on Vercel
Pattern construction cost Repeated on every hot reload Paid once per isolate
Hostname matching localhost never matches a subdomain pattern Real subdomains resolve as written
Protocol component http locally, so an https pattern fails https everywhere
Trailing-slash handling Dev servers often normalise silently Passed through as sent
Percent-encoded paths Rarely exercised Common from real clients

The protocol and hostname rows cause the most confusion. A rule written as { protocol: 'https', hostname: ':tenant.app.example.com' } cannot match http://localhost:3000 under any circumstances, so tenant routing appears completely broken locally while being correct in production. Either omit protocol — it defaults to a wildcard — or drive both from configuration and test the hostname rules against production-shaped URLs rather than against the dev server.

Validating with Vitest

Because resolve takes a URL string and returns a plain object, the suite needs no request mocking at all. Add a polyfill import to your setup file if the local Node build lacks the global.

// router.test.ts
import { describe, expect, it } from 'vitest';
import { resolve } from './lib/router';

const at = (path: string, host = 'app.example.com') => `https://${host}${path}`;

describe('resolve', () => {
  it('extracts named groups instead of positional matches', () => {
    expect(resolve(at('/api/v2/users/u_42'))).toEqual({
      name: 'api-user',
      params: { version: 'v2', id: 'u_42' },
    });
  });

  it('does not let /admin-preview through the admin rule', () => {
    expect(resolve(at('/admin-preview'))?.name).not.toBe('admin');
    expect(resolve(at('/admin'))?.name).toBe('admin');
    expect(resolve(at('/admin/users/3'))?.name).toBe('admin');
  });

  it('prefers the specific api rule over the wildcard one', () => {
    expect(resolve(at('/api/v2/users/u_42'))?.name).toBe('api-user');
    expect(resolve(at('/api/v2/invoices'))?.name).toBe('api-any');
  });

  it('captures a tenant from the hostname', () => {
    const route = resolve(at('/dashboard', 'acme.app.example.com'));
    expect(route).toEqual({ name: 'tenant-app', params: { tenant: 'acme' } });
  });

  it('drops wildcard positional groups from params', () => {
    expect(resolve(at('/_next/static/chunk.js'))).toEqual({ name: 'asset', params: {} });
  });

  it('returns null when nothing matches', () => {
    expect(resolve(at('/about'))).toBeNull();
  });
});

The second test is the one that pays for the whole exercise. It encodes the /admin-preview bug as a permanent assertion, so nobody can reintroduce a prefix check without the suite objecting. The third test does the same for rule ordering: reorder the table and it fails immediately rather than in production three weeks later.

Common pitfalls and resolutions

A rule matches more than intended — a bare /admin pattern without the optional subtree group, or a * where a :name belongs. Use '/admin{/*}?' and assert the negative case in a test.

params contains keys named 0 and 1 — those are wildcard positional groups. Filter numeric keys out, or replace the wildcard with a named group if you need its value.

A pattern matches the wrong host — you supplied only pathname, and every omitted component defaults to a wildcard. Constrain hostname explicitly whenever the rule is host-sensitive.

search pattern never matchessearch compares the query string, not parsed parameters, so extra or reordered parameters break it. Match with search: '*' and read searchParams instead.

URLPattern is not defined — the runtime lacks the global. Use the module-load branch from Step 4, and import a polyfill in your Vitest setup file so tests and production agree.

Routing works in production but not locally — a protocol: 'https' or subdomain constraint that localhost cannot satisfy. Omit protocol or feed the tests production-shaped URLs.

Routing cost grows with the rule count — patterns are being constructed inside the handler. Hoist the table to module scope and order it so common routes match early.

Production deployment checklist

Frequently Asked Questions

Is URLPattern available on every edge provider?

Cloudflare Workers and Netlify Edge Functions expose it natively with no flag, the latter through Deno. Vercel’s Edge Runtime and plain Node builds have been less consistent, so check for the global once at module load and fall back to a small regex matcher when it is absent. Making the decision at load time keeps the per-request path free of capability checks.

Why does my pattern match paths I did not intend?

Usually because a bare prefix like slash admin also matches slash admin dash preview, or because you omitted a component and it defaulted to a wildcard. Use the optional subtree syntax so the pattern requires a slash before the remainder, constrain hostname explicitly on host-sensitive rules, and add a negative assertion to the test suite.

Why are there numeric keys in my groups object?

Wildcards produce positionally numbered groups, so a pattern containing a star contributes entries keyed zero, one and so on. Filter numeric keys out when you build the params object, or replace the wildcard with a named group if the captured value is something you actually want.

Can I match on a query parameter with URLPattern?

Only crudely. The search component matches the raw query string rather than a parsed parameter set, so an extra or reordered parameter breaks the match. Match search with a wildcard and then read the parsed searchParams from a URL object, which handles ordering and repeated keys correctly.

Does compiling patterns at module scope really matter?

Yes, because construction parses the pattern and builds the matchers, and that work is identical on every request. Module scope lives for the isolate’s lifetime, so the cost is amortised across every request that isolate serves. Constructing inside the handler turns a one-time cost into a per-request one on a platform where CPU time is metered.

How should I order the rules?

Most specific first, because evaluation stops at the first match. A wildcard API rule placed above a parameterised one will swallow the request and your named groups will silently disappear. Within that constraint, put the highest-traffic routes as early as you can, and pin the ordering with a test.