Skip to content
All posts
#TypeScript#Architecture

URLPattern: Matching URLs Without a Router Library

8 min read

The URL Pattern API replaces per-framework route matchers with one standardized grammar — and Node's native implementation diverges from the spec in one narrow case.

A pattern language, not a router

Every routing library ships its own pattern matcher, and each means something slightly different by a colon. /users/:id is a path segment in Express, a named parameter in Next.js, and something else again in a service worker's scope rules. The URL Pattern API ends that divergence: it matches URLs or parts of URLs against a pattern,[1] using syntax based on the syntax from the path-to-regexp library,[2] the grammar that leaked into Rails, Express and Next.js in the first place.[2]

It reached Baseline Newly available in September 2025,[2] and web.dev framed the win plainly: a standardized, powerful, and interoperable solution that lets you stop reaching for a third-party library for routing.[6] It is available in Web Workers too.[1]

The point is that URLPattern is a protocol, not a router. It has no opinion about what happens after a match: rendering, data loading and redirects stay yours. What you stop writing is the matcher.

The grammar in one pass

A pattern is a mix of fixed text and groups. You get literal strings matched exactly, wildcards like /posts/* that match any character, named groups like /books/:id that extract part of the URL, non-capturing groups like /books{/old}? that make parts optional or repeatable, and regex groups like /books/(\d+) for arbitrary matching.[2]

An unnamed regex group is numerically indexed in the match result based on their order in the pattern.[2] On Node v26.10.0, /books/:id(\d+) yields { id: "123" } while /books/(\d+) yields { 0: "123" }.

Two wildcard forms behave differently, and it matters. The segment wildcard is non-greedy, meaning that it will match the shortest possible string,[2] while the standalone * is greedy and will match the longest possible string.[2] /:a-:b on /x-y-z therefore splits as a=x and b=y-z, while /*.png on /a/b.png captures a/b as one greedy group.[2]

Where the surprises live

The first trap is trailing slashes. Trailing slashes in a pathname are not automatically matched,[2] so /books matches https://example.com/books and /books/ matches the trailing form, but neither matches the other.[2] The idiomatic fix is a group delimiter holding a slash.[2]

The second is automatic prefixing.[2] A group that follows a slash in a pathname gets a slash prefix added.[2] So /books/:id? matches /books/123 and /books — but not /books/. Wrapping the group in delimiters, /books/{:id}?, disables automatic prefixing,[2] which flips the result: /books/ matches and /books does not.

Third, a regex for the pathname part must account for the leading slash, because the pathname URL-part always starts with /.[2] (b.*) fails against /b; (/b.*) matches /ba.[2]

Fourth, lookarounds need something to consume the characters they assert about.[2] (/a(?=b)) does not match /ab, because the engine asserts b without consuming it and then has nothing left in the pattern to match against.[2] Adding .* after the assertion fixes it.[2]

Fifth, parentheses inside a range expression have to be escaped:[2] ([()]) throws a TypeError where ([\(\)]) is accepted, unlike in RegExp.[2]

Groups, and knowing when you used a regex

Groups carry modifiers: ? for optional, + for one or more, * for zero or more.[2] A repeating group does not hand you an array. /files/:path+ against /files/a/b/c returns path: "a/b/c" as one string on Node v26.10.0, matching the documented modifier semantics.[2]

An absent optional group follows the same modifier rules.[2] /books/:id? on /books returns a groups object that has the id key present but undefined. That matters if you destructure route params — { id } is not missing, so a default applies but an in check misleads.

The hasRegExpGroups property reports whether a compiled pattern contains one or more groups which uses regular expression matching.[5] It is false for a plain named group and true once you add a regex or a capturing group — which matters because some APIs prohibit the use of regular expression groups in URLPattern objects.[2]

Object input and string input are not the same contract

Construct a pattern from an object and missing components will default to a wildcard pattern.[5] Construct it from a string and the spec always set at least an empty string value and does not default any components to wildcard patterns.[5] So new URLPattern({ pathname: "/a" }).protocol is "*", while new URLPattern("https://example.com/a").port is "".[5]

Test inputs have the mirror-image rule: missing components are treated as empty strings.[5] So new URLPattern({ pathname: "/a" }).test("https://x.com/a") is true — the wildcard hostname absorbs anything — while adding hostname: "example.com" to the same pattern makes that identical call false.

Base URLs are inherited selectively: only parts more specific than the least-specific part you supplied come from the base, and the username and password are never inherited from the base URL.[2] Supplying only a pathname inherits protocol, hostname and port, and nothing to the right of the pathname.[2] A relative constructor string with no base URL throws rather than mis-matching.[5]

Case, and the canonical form

Matching is case-sensitive by default, the opposite of what most client-side routers do.[2] An ignoreCase option enables case-insensitive matching.[2]

Patterns are rewritten before they are compiled: When a pattern is parsed it is automatically normalized to a canonical form.[2] So /book{s} reads back as /books, /{foo}/bar as /foo/bar, and /a/./b/../c collapses to /a/c.[2] Never compare pattern.pathname against the string you passed in.

One place Node still diverges

The documented behaviour is unambiguous: if a relative URL without a base URL is provided, the method will return false.[3] The specification agrees, and exec() returns an Object that defines the matching elements and groups, or null if the passed inputs do not match the pattern,[4] with the parser returning null on failure.[5]

On Node v26.10.0 that rule does not hold for object-constructed patterns.[3] new URLPattern({ pathname: "/a" }).test("/a") returns true, and exec() returns a result whose protocol and hostname inputs are empty strings. The spec-compliant polyfill, which passes the same web platform test suite,[9] returns false. String-constructed patterns behave as documented: new URLPattern("https://example.com/a").test("/a") is false on both.[3]

The test suite never asks the question: of 369 cases in the WPT urlpattern data, none passes a relative string input without a base URL — 71 of the 78 string-input cases are absolute URLs, the rest supply a base URL.[10] URLPattern landed in Node in v23.8.0 and is still marked experimental there.[11]

So pass the base URL explicitly whenever the same matcher runs on both sides.[3] Node's argument handling has needed correcting for WebIDL semantics: a merged change notes that a null or undefined base URL is stringified then rejected as invalid downstream,[13] and a related issue is still open.[14]

What it is actually good for

Link interception is the cleanest fit: new URLPattern({ hostname: "haidarrais.my.id", pathname: "/blog/*" }) matches internal blog links, rejects a lookalike host, and rejects a sibling path.[2] Route-parameter extraction is the same shape — { pathname: "/blog/:year(\\d{4})/:slug" } pulls year and slug out of a URL.[2]

Availability is the easy part of the decision. Browser support starts at Chrome 95, Firefox 142 and Safari 26, with global usage around 92%.[7][12] The feature set is tracked as urlpattern in web-features.[8]

Two sub-features trail the core in Chrome: ignoreCase at 107 and hasRegExpGroups at 122.[7] If you gate on those, gate on them specifically rather than on URLPattern itself.

The honest summary: URLPattern removes a dependency and a class of cross-library bugs, and hands you back a grammar whose edges — trailing slashes, automatic prefixing, greedy versus non-greedy wildcards, wildcard-versus-empty-string defaults — you now have to know deliberately. Each edge is documented and cheap to pin with a test.[2]

patterns.jsjavascript
// Trailing slashes are not matched by default.
new URLPattern({ pathname: "/books" }).test("https://example.com/books/");   // false

// A group delimiter holding a slash matches both forms.
const both = new URLPattern({ pathname: "/books{/}?" });
both.test("https://example.com/books");    // true
both.test("https://example.com/books/");   // true

// An optional group gets an automatic slash prefix...
new URLPattern({ pathname: "/books/:id?" }).test("https://example.com/books");  // true

// ...unless you wrap it in delimiters, which disables prefixing.
new URLPattern({ pathname: "/books/{:id}?" }).test("https://example.com/books"); // false
new URLPattern({ pathname: "/books/{:id}?" }).test("https://example.com/books/"); // true
wildcards.jsjavascript
// The segment wildcard is non-greedy; * is greedy.
const segments = new URLPattern({ pathname: "/:a-:b" });
segments.exec("https://example.com/x-y-z").pathname.groups;
// { a: "x", b: "y-z" }

const greedy = new URLPattern({ pathname: "/*.png" });
greedy.exec("https://example.com/a/b.png").pathname.groups;
// { 0: "a/b" }
groups.jsjavascript
// Named groups are keyed by name; unnamed groups by index.
new URLPattern("/books/:id(\\d+)", "https://example.com")
  .exec("https://example.com/books/123").pathname.groups;   // { id: "123" }

new URLPattern("/books/(\\d+)", "https://example.com")
  .exec("https://example.com/books/123").pathname.groups;   // { 0: "123" }

// A repeating group yields one string, not an array.
new URLPattern({ pathname: "/files/:path+" })
  .exec("https://x.com/files/a/b/c").pathname.groups;       // { path: "a/b/c" }

// An absent optional group leaves the key present but undefined.
const g = new URLPattern({ pathname: "/books/:id?" })
  .exec("https://x.com/books").pathname.groups;
"id" in g;            // true
g.id;                 // undefined

// hasRegExpGroups tells you whether the pattern embeds a regex.
new URLPattern({ pathname: "/a/:x" }).hasRegExpGroups;      // false
new URLPattern({ pathname: "/a/(\\d+)" }).hasRegExpGroups;   // true
node-divergence.jsjavascript
// Documented: a relative URL with no base URL returns false.
new URLPattern("https://example.com/a").test("/a");              // false (correct)

// Node v26.10.0 with an object-constructed pattern: true.
new URLPattern({ pathname: "/a" }).test("/a");                   // true  <- diverges
new URLPattern({ pathname: "/a" }).exec("/a").protocol.input;    // ""

// The spec-compliant polyfill returns false for the same call.
// urlpattern-polyfill passes the same web platform test suite.

// Portable fix: always pass the base URL explicitly.
new URLPattern({ hostname: "example.com", pathname: "/a" })
  .test("/a", "https://example.com");                            // true

Sources

[1] https://developer.mozilla.org/en-US/docs/Web/API/URLPattern — URLPattern - Web APIs | MDN

[2] https://developer.mozilla.org/en-US/docs/Web/API/URL_Pattern_API — URL Pattern API - Web APIs | MDN

[3] https://developer.mozilla.org/en-US/docs/Web/API/URLPattern/test — URLPattern: test() method - Web APIs | MDN

[4] https://developer.mozilla.org/en-US/docs/Web/API/URLPattern/exec — URLPattern: exec() method - Web APIs | MDN

[5] https://urlpattern.spec.whatwg.org — URL Pattern Standard (WHATWG Living Standard)

[6] https://web.dev/blog/baseline-urlpattern — URLPattern is now Baseline Newly available - web.dev

[7] https://github.com/mdn/browser-compat-data/blob/main/api/URLPattern.json — browser-compat-data: api/URLPattern.json

[8] https://github.com/web-platform-dx/web-features/blob/main/features/urlpattern.yml — web-features: urlpattern.yml

[9] https://github.com/kenchris/urlpattern-polyfill — kenchris/urlpattern-polyfill (spec-compliant polyfill)

[10] https://github.com/web-platform-tests/wpt/blob/master/urlpattern/resources/urlpatterntestdata.json — WPT: urlpattern test data

[11] https://nodejs.org/api/url.html — Node.js URL documentation - Class: URLPattern

[12] https://caniuse.com/mdn-api_urlpattern — Can I use: URLPattern API

[13] https://github.com/nodejs/node/pull/62719 — nodejs/node PR 62719 - align default argument handling for URLPattern with webidl

[14] https://github.com/nodejs/node/issues/64785 — nodejs/node issue 64785 - URLPattern rejects values requiring WebIDL USVString conversion