URLPattern compiles a pattern string into a URL matcher with named groups, so you can do routing without path-to-regexp or a hand-rolled regular expression. MDN lists it as Baseline 2025 Newly available: since September 2025 it works in current Chrome, Firefox and Safari, and it is in Node, Deno, Bun and Cloudflare Workers as well.
The whole router is an array and a loop:
const routes = [
['/products/new', newProductForm],
['/products/:handle', showProduct],
['/collections/:handle/page/:page(\\d+)', showCollectionPage],
].map(([pathname, handler]) => [new URLPattern({ pathname }), handler]);
export function route(request) {
for (const [pattern, handler] of routes) {
const match = pattern.exec(request.url);
if (match) return handler(match.pathname.groups);
}
return new Response('Not found', { status: 404 });
}
Order matters there, and it is easy to get wrong. /products/new matches both of the first two patterns. Nothing in the API ranks them: test() and exec() each compare one compiled pattern against one URL, so the array order is your precedence rule. Put the literal route above the parameterised one or the form never renders.
What does URLPattern match, exactly?
The syntax comes from path-to-regexp, and the URL Pattern Standard defines four building blocks.
:name is a named group. In the pathname it stops at the next /, so /files/:name will not match /files/a/b/c. * is the greedy one, described in the spec as “a matching group that greedily matches all code points”, so /files/* does match that path. (\d+) is a regular expression group, and it has a sharp edge: Chrome’s documentation states that “Regular expression groups must contain only ASCII characters”, so anything non-ASCII has to be percent-encoded by hand.
The fourth is modifiers, and this is where the automatic prefix bites. Write /p/:id? and the optional group swallows the slash in front of it, so the pattern matches /p as well as /p/42. Wrap the group in braces to take that slash back:
for (const pathname of ['/p/:id?', '/p/{:id}?', '/p{/:id}?']) {
const p = new URLPattern({ pathname });
console.log(pathname, p.test('https://x/p'), p.test('https://x/p/'), p.test('https://x/p/42'));
}
// /p/:id? true false true
// /p/{:id}? false true true
// /p{/:id}? true false true
/p/{:id}? makes only :id optional and leaves the slash mandatory, which is almost never what you want for an optional path segment. /p{/:id}? is the form that reads like the intent.
exec() returns an object with one entry per URL component, each carrying its own input and groups, so a pathname capture lands at match.pathname.groups.handle and a hostname capture at match.hostname.groups.subdomain. test() returns a boolean and nothing else. If you only need the yes or no, use it.
One more parsing quirk. A colon in literal text is ambiguous against the named-group syntax, so it has to be escaped: Chrome’s guide uses 'about\\:blank' as the example.
Why does my pattern match the wrong hostname?
Because you only specified the pathname. MDN is blunt about it: “Properties that are omitted or not filled by the baseURL property default to the wildcard string (*), which match against any corresponding value in a URL.”
So this pattern matches a request to any host on any protocol:
const p = new URLPattern({ pathname: '/products/:id' });
console.log(p.protocol, p.hostname); // '*' '*'
console.log(p.test('https://evil.example/products/42')); // true
Inside a fetch handler that is usually harmless, since you are already matching a request that arrived at your own origin. It stops being harmless the moment you use URLPattern to decide whether a URL from user input, a redirect target or an OAuth redirect_uri is one of yours. Pin the components you care about, or pass a base URL and let the constructor pin them for you.
The inheritance rules for a base URL are worth knowing, because they are asymmetric. MDN: “If the pathname part is specified in the input, the parts to its left may be inherited from the base URL (protocol, hostname and port), while the parts to its right may not (search and hash). The username and password are never inherited from the base URL.”
Two primary sources disagree about the rest. Chrome’s URLPattern article says that with a base URL, “any aspects of the URL that are not provided are treated as if they were set to an empty string, not as a '*' wildcard”. Current MDN describes the wildcard default instead. Neither is a typo, because the implementations changed. Cloudflare gates this on a compatibility flag, urlpattern_standard, which swaps an earlier implementation that was not fully compliant with the standard for a spec-compliant one. It is on by default from a compatibility date of 2025-05-01, and urlpattern_original goes back. Follow MDN and the spec, which is the direction that flag points, and print the compiled pattern to confirm what your own runtime does:
const p = new URLPattern('/products/:id', 'https://shop.example');
console.log({ protocol: p.protocol, hostname: p.hostname, search: p.search, hash: p.hash });
The constructor also throws TypeError more often than you would expect: on a syntactically invalid pattern, on a relative url with no baseURL, and on a baseURL passed alongside an absolute pattern or an object input. Build your patterns once at module scope, not per request, and a bad pattern fails at import rather than on a live route.
Where can I use URLPattern routing today?
In browsers, the compatibility data puts the constructor at Chrome 95, Firefox 142 and Safari 26. Check the sub-rows before you assume the whole API arrived together, because in Chrome it did not: ignoreCase landed in Chrome 107 and hasRegExpGroups in Chrome 122, while Firefox and Safari shipped all three at once.
On the server the picture is good and the small print matters. Node.js added URLPattern to node:url in v23.8.0 and exposed it as a global in v24.0.0, and the docs mark it Stability 1 - Experimental in both places. Node’s stability index is specific about what that costs you: an experimental feature “is not subject to semantic versioning rules”, non-backward-compatible changes or removal “may occur in any future release”, and use of it “is not recommended in production environments”. That is a reason to pin your Node version, not a reason to avoid the API. Deno has had it since 1.15 and Bun since 1.3.4. Cloudflare Workers has it in the runtime, with the flag above choosing which implementation you get.
If you need it further back than Baseline, the urlpattern-polyfill package exists, and Chrome’s guidance is to feature-detect before loading it so supporting browsers never pay for the download.
What it still will not do
Three gaps. Each of them is work a routing library was already doing for you.
It matches one pattern at a time. MDN documents two matching methods, test() and exec(), and both compare a single compiled pattern against a single URL. Route ranking, the rule that decides /products/new beats /products/:handle, is your loop and your array order.
It cannot generate a URL. The documented interface is test(), exec() and the eight read-only component strings, so turning a route plus parameters back into a path is something you keep writing yourself after you drop path-to-regexp.
The search component is matched as one string, not per parameter. new URLPattern({ pathname: '/p', search: 'ref=:r' }) matches /p?ref=a and misses /p?a=1&ref=a, because the pattern has to match the whole query string in order. Match the path with URLPattern and read parameters with URLSearchParams.
Use URLPattern when you are writing the routing yourself: a service worker deciding what to serve from cache, a Worker or a Node handler with a handful of routes, a small client-side router where the alternative is a regex nobody wants to touch. It pairs naturally with the browser history work we covered in our comparison of the Navigation API and the History API. Skip it when your framework already ships a router that ranks routes and generates URLs, since you would be rebuilding both. Skip it on the server if Node’s “not recommended in production environments” is a line you cannot sign off, and reach for urlpattern-polyfill or path-to-regexp instead.
We write TypeScript and Node.js services where request routing sits in front of everything else, and we do the same work on Cloudflare, which came up in the piece on deploying Next.js to Workers. If a routing layer has grown organically and nobody wants to touch it, that is the sort of thing our Node.js development work covers.