URLPattern: The Built-In Way to Match and Parse URLs in JavaScript (Browsers and Node)
A practical guide to the URLPattern API: pattern syntax (:id, wildcards, regex groups, modifiers), test() vs exec(), baseURL rules, ignoreCase, a client-side router in 30 lines, service-worker routing, Node 24 global support, Baseline status and the gotchas.
Every router, every service worker and most middleware eventually contains the same hand-rolled code: split a pathname on slashes, compare segments, pull out an ID, and hope nobody passes a trailing slash. The URLPattern API is the platform's answer. It gives you a URLPattern object that matches URLs the way RegExp matches strings, using the :id and * syntax you already know from Express and Next.js, and it returns the named groups as a plain object. It shipped in Chrome 95 in 2021, reached Firefox 142 and Safari 26 in 2025, became Baseline Newly available in September 2025, and has been a global in Node.js since version 24. This post covers the syntax, the two methods, the base-URL rules that trip people up, and three real uses.
The basics: test() and exec()
A pattern is built from either a full URL string or an object with one entry per URL component. The simplest useful case is a pathname with a named group:
const pattern = new URLPattern({ pathname: '/books/:id' });
pattern.test('https://example.com/books/123'); // true
pattern.test('https://example.com/books'); // false
const match = pattern.exec('https://example.com/books/123');
match.pathname.groups.id; // '123'
match.pathname.input; // '/books/123'test() returns a boolean. exec() returns null on no match, otherwise an object with one entry per component (protocol, username, password, hostname, port, pathname, search, hash), each carrying the input that was matched and a groups object of captures. Components you do not specify in the pattern default to the wildcard *, so the pattern above matches that path on any host, any protocol, with any query string.
You can also pass a URL-like object to test() and exec() instead of a string, which is handy when you only have a pathname: pattern.exec({ pathname: '/books/123' }) works and does not require a host at all.
The pattern syntax, in one table's worth of examples
The syntax follows the path-to-regexp library, so it will look familiar if you have used Express 4 or earlier. The important forms:
// Named group: matches one segment
new URLPattern({ pathname: '/users/:id' });
// Named group with a regex constraint: digits only
new URLPattern({ pathname: '/users/:id(\\d+)' });
// Unnamed regex group: available as groups[0]
new URLPattern({ pathname: '/(foo|bar)' });
// Wildcard: zero or more of any character, greedy
new URLPattern({ pathname: '/assets/*' });
// Modifiers on groups: optional, one-or-more, zero-or-more
new URLPattern({ pathname: '/books/:id?' }); // /books or /books/1
new URLPattern({ pathname: '/files/:path+' }); // one or more segments
new URLPattern({ pathname: '/files/:path*' }); // zero or more segments
// Braces group fixed text so a modifier can apply to it
new URLPattern({ pathname: '/book{s}?' }); // /book or /books
new URLPattern({ pathname: '/docs{/}?' }); // with or without trailing slash
// Hostname patterns work the same way
new URLPattern({ hostname: '{:subdomain.}*example.com' });Two details matter in practice. First, in a pathname pattern a group that follows a / gets that slash as an automatic prefix, which is why /books/:id? matches /books rather than /books/. If you want the literal behaviour, wrap the group in braces: /books/{:id}? requires the slash. Second, trailing slashes are not matched by default; /books and /books/ are different patterns, and the {/}? idiom above is the standard way to accept both.
Regex groups support lookahead and lookbehind, but the pattern string is parsed before the regex is compiled, so parentheses inside character classes must be escaped: write ([\\(\\)]), not [()]. If a pattern contains any regex group at all, its hasRegExpGroups property is true; some engines take a slower path for those, so prefer named groups and wildcards unless you need a constraint.
Base URLs and the inheritance rule
The constructor accepts a second argument, a base URL, and the object form accepts a baseURL property. This is where most confusion comes from. A base URL does not simply fill in missing parts; it fills in the parts that are less specific than the most specific part you supplied, in the order protocol, hostname, port, pathname, search, hash.
const p = new URLPattern('/foo/*', 'https://example.com');
p.protocol; // 'https' (inherited)
p.hostname; // 'example.com' (inherited)
p.pathname; // '/foo/*'
p.search; // '*' (not inherited: more specific than pathname)
// Without a base, everything unspecified is a wildcard
const q = new URLPattern({ pathname: '/foo/*' });
q.hostname; // '*'So new URLPattern('/foo/*', 'https://example.com') is pinned to that host, while new URLPattern({ pathname: '/foo/*' }) matches the path anywhere. Pick deliberately: a router inside a single-origin app wants the second form; a service worker that must not intercept third-party requests wants the first. The same base-URL argument can also be passed to test() and exec(), so pattern.test('/foo/bar', 'https://example.com/baz') resolves the relative input before matching.
Case sensitivity
Matching is case-sensitive by default, which is correct for pathnames on most servers but surprising for people coming from Windows-style routing. The options object as the second argument switches it:
const p = new URLPattern('https://example.com/2026/oct/*', { ignoreCase: true });
p.test('https://example.com/2026/Oct/notes'); // trueNote the second argument is either a base-URL string or an options object, not both; if you need a base URL and ignoreCase, use the object form with a baseURL property and pass the options as the second argument.
Use 1: a client-side router in thirty lines
The obvious use is replacing a routing library in small apps. Each route is a pattern plus a handler; on navigation you find the first pattern that matches and hand it the groups. Combined with the Navigation API this is a complete single-page router with no dependencies.
const routes = [
{ pattern: new URLPattern({ pathname: '/' }), render: Home },
{ pattern: new URLPattern({ pathname: '/posts/:slug' }), render: Post },
{ pattern: new URLPattern({ pathname: '/tags/:tag+' }), render: Tag },
];
function resolve(url) {
for (const route of routes) {
const match = route.pattern.exec(url);
if (match) return route.render(match.pathname.groups);
}
return NotFound();
}
// With the Navigation API (Chromium today; falls back to location in others)
navigation?.addEventListener('navigate', (event) => {
if (!event.canIntercept || event.hashChange) return;
event.intercept({ handler: () => resolve(event.destination.url) });
});
resolve(location.href);Order matters, exactly as it does in Express: put specific routes before wildcards. Because exec() returns the groups already split out, the handlers never touch the raw path. If you have not met the Navigation API, the practical guide to it covers the interception model used above.
Use 2: routing inside a service worker
Service workers were the original motivation for the API. A fetch handler that decides caching strategy by URL is usually a wall of url.pathname.startsWith(...); patterns make the intent readable and let you pin the origin so you never cache someone else's responses by accident.
const images = new URLPattern({ pathname: '/images/*.(png|jpg|webp)', baseURL: self.location.origin });
const api = new URLPattern({ pathname: '/api/:version/*', baseURL: self.location.origin });
self.addEventListener('fetch', (event) => {
const url = event.request.url;
if (images.test(url)) {
event.respondWith(cacheFirst(event.request));
} else if (api.test(url)) {
const { version } = api.exec(url).pathname.groups;
event.respondWith(networkFirst(event.request, version));
}
});Alternation is a regex group, (png|jpg|webp), not a brace list; braces only group text so a modifier can apply to it. That is the kind of detail that is easy to get subtly wrong, so keep a small test file of URLs that should and should not match and run it in CI.
Use 3: Node.js servers and edge functions
Node.js 24 exposes URLPattern on the global object with no import, and the same class is available in Deno, Bun and Cloudflare Workers. That makes it a reasonable zero-dependency router for small HTTP services and for edge middleware, where every kilobyte of bundle is paid on each cold start.
import { createServer } from 'node:http';
const userById = new URLPattern({ pathname: '/users/:id(\\d+)' });
createServer((req, res) => {
const url = new URL(req.url, 'http://localhost');
const m = userById.exec(url);
if (m && req.method === 'GET') {
res.end(JSON.stringify({ id: Number(m.pathname.groups.id) }));
return;
}
res.statusCode = 404;
res.end();
}).listen(3000);On Node 22 and earlier the class is not global; the urlpattern-polyfill package provides it and is what most frameworks that support older runtimes ship.
Support and polyfill
- Chrome and Edge 95 and later, including inside workers and service workers.
- Firefox 142 and later (August 2025).
- Safari 26 and later (September 2025). The feature is Baseline 2025, Newly available, which means it works in current browsers but not in the two-to-three-year-old ones a cautious team still supports.
- Node.js 24 and later as a global; Deno, Bun and Cloudflare Workers also provide it.
- Elsewhere, the
urlpattern-polyfillpackage implements the WHATWG spec and can be loaded conditionally withif (!('URLPattern' in globalThis)).
Gotchas worth knowing before you ship
- A pathname pattern must start with
/; a pattern ofbooks/:idwill not match anything, silently. - Unspecified components are wildcards, not empty. A pattern with only
pathnamematches on every host. Sethostnameor use a base URL when that matters, especially in service workers. searchandhashare matched too. If you leave them out they are*, which is what you want; if you setsearch: ''the pattern only matches URLs with no query string.- Wildcards are greedy.
/a/*/bon/a/x/b/y/bcapturesx/b/y. - Group values come back URL-encoded as they appeared in the input; decode with
decodeURIComponentif you are going to display them. - Constructing patterns is comparatively expensive. Build them once at module load, not inside the request handler.
- TypeScript: recent versions of the DOM lib and of
@types/nodedeclareURLPattern; if your toolchain does not know the type, the polyfill package ships its own declarations anddeclare globalis a one-line fix.
When not to use it
URLPattern is a matcher, not a router. It does not do route ranking, nested layouts, data loading, prefetching or history management; if you need those, a framework router is still the right call, and several of them use URLPattern internally anyway. It is also not a replacement for the URL class: parse with URL, match with URLPattern. And if your matching is a single startsWith, keep the startsWith; the API earns its place when there are several routes with parameters, which is exactly when hand-written parsing starts to grow bugs.
For the other side of the same problem, building a URL rather than reading one, URL and URLSearchParams remain the tools; and if you are doing this inside an abortable fetch pipeline, the AbortController guide pairs naturally with pattern-based routing in workers.