URLPattern in JavaScript: match a URL and pull out its parts

URLPattern checks a URL against a pattern such as /books/:id and hands back the named parts. It does the job of the hand-written regular expressions a small router would otherwise need.

URLPattern is a built-in JavaScript class that matches a URL against a pattern and returns the named parts. Create one with a pathname such as /books/:id, then test(url) answers yes or no and exec(url) returns { id: '42' } for /books/42.

Try it. Change the pattern or the URL, or press one of the sample paths.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>URLPattern basics</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  label { display: block; font-size: 13px; font-weight: 600; margin: 10px 0 4px; }
  input { box-sizing: border-box; width: 100%; padding: 9px 10px; font: 15px ui-monospace, Consolas, monospace;
          border: 1px solid #cfd4dc; border-radius: 8px; background: #fff; }
  .try { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
  .try button { font: 12px ui-monospace, Consolas, monospace; padding: 6px 8px; border: 1px solid #cfd4dc;
                border-radius: 6px; background: #fff; cursor: pointer; }
  #out { margin-top: 14px; padding: 12px; border-radius: 10px; background: #fff; border: 1px solid #e1e4ea;
         font: 14px/1.5 ui-monospace, Consolas, monospace; white-space: pre-wrap; word-break: break-all; }
  #out.yes { border-color: #9fd8b3; background: #f4fbf6; }
  #out.no  { border-color: #f3c5b8; background: #fff7f5; }
</style>
</head>
<body>
<label for="pat">Pattern (pathname)</label>
<input id="pat" value="/books/:id" spellcheck="false" autocomplete="off">
<label for="url">URL to test</label>
<input id="url" value="https://example.com/books/42?tab=reviews" spellcheck="false" autocomplete="off">
<div class="try">
  <button data-url="https://example.com/books/42?tab=reviews">/books/42</button>
  <button data-url="https://example.com/books/">/books/</button>
  <button data-url="https://example.com/books/42/">/books/42/</button>
  <button data-url="https://example.com/authors/7">/authors/7</button>
</div>
<div id="out"></div>

<script>
  const pat = document.getElementById('pat');
  const url = document.getElementById('url');
  const out = document.getElementById('out');

  function run() {
    if (!('URLPattern' in globalThis)) {
      out.className = 'no';
      out.textContent = 'This browser has no URLPattern. See the fallback example.';
      return;
    }
    try {
      // Only pathname is given, so protocol, host, search and hash match anything
      const pattern = new URLPattern({ pathname: pat.value });
      const m = pattern.exec(url.value);
      out.className = m ? 'yes' : 'no';
      out.textContent = m
        ? 'test(): true\ngroups: ' + JSON.stringify(m.pathname.groups)
        : 'test(): false\nexec() returned null';
    } catch (err) {
      out.className = 'no';
      out.textContent = err.name + ': ' + err.message;  // bad pattern or bad URL
    }
  }

  pat.addEventListener('input', run);
  url.addEventListener('input', run);
  document.querySelectorAll('[data-url]').forEach((b) =>
    b.addEventListener('click', () => { url.value = b.dataset.url; run(); }));
  run();
</script>
</body>
</html>
Type a pathname pattern and a URL. The result updates on every key.

You write no regular expression and load no library. The browser parses both the pattern and the URL, so a query string or a #hash on the end does not break the match.

What a pattern is made of

A URL has several parts: protocol, username, password, hostname, port, pathname, search and hash. A URLPattern holds one pattern per part. Any part you leave out becomes * and accepts every value.

Only the pathname is set, so the other parts are not checked.
Only the pathname is set, so the other parts are not checked.

There are two ways to build one. The object form names the parts. The string form reads a whole URL pattern, and a relative one needs a base URL as the second argument:

// Object form: only the pathname is checked
const byPath = new URLPattern({ pathname: '/books/:id' });

// String form: the base fills in protocol and host
const onSite = new URLPattern('/books/:id', 'https://example.com');
onSite.test('https://example.com/books/7'); // true
onSite.test('https://other.org/books/7');   // false: different host

A relative string with no base, such as new URLPattern('/books/:id'), throws a TypeError. So does a broken pattern like /a/: with nothing after the colon.

test() and exec()

Both take the URL to check. exec() returns null on no match. On a match it returns one entry per part, and the values you want are under groups:

const p = new URLPattern({ pathname: '/books/:id' });
const m = p.exec('https://example.com/books/42?tab=info');
m.pathname.groups.id;   // "42"
m.pathname.input;       // "/books/42"

Three details matter in practice:

  • Relative paths need a base. p.test('/books/42') returns false. Pass a base, p.test('/books/42', location.href), or pass an object such as { pathname: '/books/42' }.
  • Values stay encoded. A path of /books/caf%C3%A9 gives an id of caf%C3%A9. Run decodeURIComponent on it when you display it.
  • Matching is case-sensitive. Pass { ignoreCase: true } as the last constructor argument to change that.

Named groups, wildcards and optional parts

The pathname syntax is short. Each piece below was run against the paths shown:

Green paths match, orange paths do not. The groups column is what exec() returns.
Green paths match, orange paths do not. The groups column is what exec() returns.
Syntax Means Example match
:id One segment, up to the next / /books/42
:id? The segment is optional /books and /books/42
:tag+ One or more segments /tags/a/b
* Anything, slashes included, stored as group 0 /files/a/b.txt
{/}? Optional literal text in braces /books/42/
:id([0-9]+) A segment that must match a RegExp /books/42, not /books/abc

A custom group is plain RegExp text in brackets. Inside a JavaScript string a backslash has to be doubled, or the pattern quietly loses it:

new URLPattern({ pathname: '/books/:id(\\d+)' }); // right: \d reaches URLPattern
new URLPattern({ pathname: '/books/:id([0-9]+)' }); // same, no backslash needed

There is also a search part, but it matches the query text in order, so q=* does not match ?page=2&q=cat. Match the path with URLPattern and read the query with URLSearchParams.

A tiny router in one file

A router is a list of patterns tried in order. The first one that matches decides what the page shows. This one runs entirely in the page, so the links never load anything:

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>A tiny router with URLPattern</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  form { display: flex; gap: 6px; }
  #addr { flex: 1; min-width: 0; padding: 8px 12px; font: 14px ui-monospace, Consolas, monospace;
          border: 1px solid #cfd4dc; border-radius: 99px; background: #fff; }
  form button { padding: 8px 14px; border: 0; border-radius: 99px; background: #1d2330; color: #fff; cursor: pointer; }
  nav { display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 0; }
  nav a { font-size: 13px; padding: 6px 10px; border-radius: 99px; background: #fff; border: 1px solid #dfe3ea;
          color: #1d4ed8; text-decoration: none; }
  #view { background: #fff; border: 1px solid #e1e4ea; border-radius: 12px; padding: 14px 16px; min-height: 110px; }
  #view h2 { margin: 0 0 6px; font-size: 20px; }
  #view p { margin: 4px 0; font-size: 14px; color: #4b5563; word-break: break-all; }
  #engine { margin-top: 8px; font-size: 12px; color: #6b7280; }
</style>
</head>
<body>
<form id="go"><input id="addr" aria-label="Path" value="/" spellcheck="false" autocomplete="off"><button>Go</button></form>
<nav>
  <a href="/" data-link>Home</a>
  <a href="/books" data-link>Books</a>
  <a href="/books/42" data-link>Book 42</a>
  <a href="/books/7/reviews" data-link>Reviews of 7</a>
  <a href="/files/docs/2026/plan.pdf" data-link>A file</a>
  <a href="/nowhere" data-link>Broken link</a>
</nav>
<div id="view"><h2></h2><p class="text"></p><p class="route"></p></div>
<div id="engine"></div>

<script>
  // The first route that matches wins, so put specific routes first
  const routes = [
    { path: '/',                  view: () => ['Home', 'Pick a link above.'] },
    { path: '/books',             view: () => ['All books', 'A list would go here.'] },
    { path: '/books/:id',         view: (g) => ['Book ' + g.id, 'id = ' + g.id] },
    { path: '/books/:id/reviews', view: (g) => ['Reviews', 'Reviews for book ' + g.id] },
    { path: '/files/*',           view: (g) => ['File', 'Rest of the path: ' + g[0]] },
  ];

  // URLPattern when the browser has it, a RegExp otherwise
  const native = 'URLPattern' in globalThis;
  function compile(path) {
    if (native) {
      const p = new URLPattern({ pathname: path });
      return (url) => p.exec({ pathname: url })?.pathname.groups ?? null;
    }
    const re = new RegExp('^' + path.replace(/:(\w+)/g, '(?<$1>[^/]+)').replace('*', '(?<rest>.*)') + '$');
    return (url) => { const m = re.exec(url); return m && { ...m.groups, 0: m.groups?.rest }; };
  }
  routes.forEach((r) => { r.match = compile(r.path); });

  function show(path) {
    document.getElementById('addr').value = path;
    let title = 'Not found', text = 'No route matches ' + path, used = '';
    for (const r of routes) {
      const groups = r.match(path);
      if (groups) { [title, text] = r.view(groups); used = 'Route: ' + r.path; break; }
    }
    // textContent, so text from the URL is never read as HTML
    document.querySelector('#view h2').textContent = title;
    document.querySelector('#view .text').textContent = text;
    document.querySelector('#view .route').textContent = used;
  }

  // Handle link clicks here instead of loading a new page
  document.querySelectorAll('[data-link]').forEach((a) =>
    a.addEventListener('click', (e) => { e.preventDefault(); show(a.getAttribute('href')); }));
  document.getElementById('go').addEventListener('submit', (e) => {
    e.preventDefault();
    show(document.getElementById('addr').value.trim() || '/');
  });

  document.getElementById('engine').textContent = 'Matcher: ' + (native ? 'URLPattern' : 'RegExp fallback');
  show('/');
</script>
</body>
</html>
Click the links or type a path and press Go. Unknown paths fall through to Not found.
  1. List the routes, each with a pathname pattern and a function that returns the view.
  2. Compile each pattern once when the page loads.
  3. Find the first match by calling exec() on each route in turn.
  4. Draw the view from the groups, using textContent so text from the URL is never read as HTML.
  5. Handle link clicks with preventDefault() and run the router with the link's path.
const routes = [
  { path: '/books/:id', view: (g) => 'Book ' + g.id },
  { path: '/files/*',   view: (g) => 'File ' + g[0] },
];
routes.forEach((r) => { r.pattern = new URLPattern({ pathname: r.path }); });

function route(path) {
  for (const r of routes) {
    const m = r.pattern.exec({ pathname: path });
    if (m) return r.view(m.pathname.groups);
  }
  return 'Not found';
}

Order matters. Put specific routes before general ones, or a catch-all such as /* placed first will take every path.

For a single-file page that keeps its state in the part after #, give the pattern a hash part such as /post/:id instead of a pathname, and run the router on hashchange. location.hash in JavaScript covers that event.

A RegExp fallback when URLPattern is missing

URLPattern is a newer API than URL and URLSearchParams. Check for it once, and build a regular expression when it is not there:

One check at load time picks the engine. Both return the same groups object.
One check at load time picks the engine. Both return the same groups object.
function toRegExp(pattern) {
  let n = 0;
  const src = pattern
    .replace(/[.+?^${}()|[\]\\]/g, '\\$&')        // escape RegExp characters
    .replace(/:(\w+)/g, '(?<$1>[^/]+)')            // :id -> one segment
    .replace(/\*/g, () => '(?<_' + n++ + '>.*)');  // *   -> anything
  return new RegExp('^' + src + '$');
}

const matcher = 'URLPattern' in globalThis
  ? new URLPattern({ pathname: '/books/:id' })
  : toRegExp('/books/:id');

The fallback understands :name and * only. Keep the patterns in your routes to those two if the page has to run on both paths. The next example runs both engines on the same paths. Tick the box to force the fallback and compare:

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>URLPattern with a RegExp fallback</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .bar { display: flex; flex-wrap: wrap; gap: 8px 14px; align-items: center; font-size: 14px; }
  select { font: 14px ui-monospace, Consolas, monospace; padding: 6px; border-radius: 6px; border: 1px solid #cfd4dc; }
  #engine { margin: 10px 0 6px; font-size: 13px; color: #4b5563; }
  #regex { font: 12px ui-monospace, Consolas, monospace; background: #fff; border: 1px solid #e1e4ea;
           border-radius: 8px; padding: 8px; word-break: break-all; }
  table { width: 100%; border-collapse: collapse; margin-top: 10px; background: #fff; font-size: 13px; }
  th, td { text-align: left; padding: 7px 8px; border-bottom: 1px solid #eceef2; vertical-align: top; }
  td { font-family: ui-monospace, Consolas, monospace; word-break: break-all; }
  .y { color: #0f5132; } .n { color: #9a3412; }
</style>
</head>
<body>
<div class="bar">
  <label>Pattern
    <select id="pat">
      <option>/books/:id</option>
      <option>/books/:id/reviews</option>
      <option>/files/*</option>
      <option>/:lang/docs/:page</option>
    </select>
  </label>
  <label><input type="checkbox" id="force"> Pretend URLPattern is missing</label>
</div>
<div id="engine"></div>
<div id="regex"></div>
<table>
  <thead><tr><th>Path</th><th>Result</th></tr></thead>
  <tbody id="rows"></tbody>
</table>

<script>
  const paths = ['/books/42', '/books/42/reviews', '/books/', '/files/docs/a.txt', '/en/docs/intro', '/about'];

  // Fallback: ':name' becomes a named group, '*' becomes "anything".
  // It covers only :name and *. URLPattern understands much more.
  function toRegExp(pattern) {
    let n = 0;
    const src = pattern
      .replace(/[.+?^${}()|[\]\\]/g, '\\$&')        // escape RegExp characters
      .replace(/:(\w+)/g, '(?<$1>[^/]+)')            // :id -> one path segment
      .replace(/\*/g, () => '(?<_' + n++ + '>.*)');  // *   -> anything, named _0, _1...
    return new RegExp('^' + src + '$');
  }

  // One interface for both engines: a path goes in, groups (or null) come out
  function makeMatcher(pattern, useFallback) {
    if (!useFallback && 'URLPattern' in globalThis) {
      const p = new URLPattern({ pathname: pattern });
      return { engine: 'URLPattern', match: (path) => p.exec({ pathname: path })?.pathname.groups ?? null };
    }
    const re = toRegExp(pattern);
    return {
      engine: 'RegExp fallback', re,
      match(path) {
        const m = re.exec(path);
        if (!m) return null;
        const groups = {};  // rename _0 to 0 so both engines return the same shape
        for (const [k, v] of Object.entries(m.groups || {})) groups[k.replace(/^_(\d+)$/, '$1')] = v;
        return groups;
      },
    };
  }

  function render() {
    const force = document.getElementById('force').checked;
    const m = makeMatcher(document.getElementById('pat').value, force);
    document.getElementById('engine').textContent = 'Matching with: ' + m.engine;
    document.getElementById('regex').textContent = m.re ? 'RegExp: ' + m.re : 'new URLPattern({ pathname: pattern })';
    const rows = document.getElementById('rows');
    rows.textContent = '';
    for (const path of paths) {
      const g = m.match(path);
      const tr = rows.insertRow();
      tr.insertCell().textContent = path;
      const td = tr.insertCell();
      td.className = g ? 'y' : 'n';
      td.textContent = g ? JSON.stringify(g) : 'no match';
    }
  }

  document.getElementById('pat').addEventListener('change', render);
  document.getElementById('force').addEventListener('change', render);
  render();
</script>
</body>
</html>
Pick a pattern. The rows stay the same whether URLPattern or the RegExp does the matching.

The wildcard group is named _0 in the RegExp, because a group name cannot be a number. The example renames it to 0 so both engines return the same shape.

If you need the full syntax without native support, a polyfill package for URLPattern is published on npm.

Which browsers were tested

The examples and every pattern in the syntax picture were run in the Chromium, Firefox and WebKit builds installed with Playwright, the browser testing tool. All three have URLPattern and gave the same result on every check.

That includes the trailing slash, the optional group and the [0-9]+ group. Older browser versions and real phones were not tested, so keep the feature check.

When it does not work

What you see Cause Fix
URLPattern is not defined error The browser has no URLPattern Check 'URLPattern' in globalThis and fall back to a RegExp
TypeError when creating the pattern A relative string with no base, or a broken pattern Use the object form, or pass a base URL
test('/books/42') is false A relative URL with no base Pass location.href as the second argument
/books/42/ does not match The trailing slash is left over Add {/}? to the pattern
(\d+) does not match numbers The string ate the backslash Write \\d, or use [0-9]
The wrong route wins A general pattern comes first Put specific routes first
Values show %20 Groups are not decoded decodeURIComponent(value)

A router is hard to show in a screenshot, since the point is clicking through it. An .html attachment may open as plain code, or not at all, on a phone.

To send the working version, paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so the people you send it to can click the links and type paths themselves. If you change the routes later, the same link shows the new version.

Questions people ask

What is the difference between test() and exec()?

test() returns true or false. exec() returns null when the URL does not match, and otherwise an object with one entry per URL part (pathname, search, hash and so on), each holding the input text and a groups object with the named values.

Why does /books/:id not match /books/42/?

Patterns are exact. :id stops at a slash, so the trailing slash is left over and the match fails. Write /books/:id{/}? to make the final slash optional.

Is URLPattern case-sensitive?

Yes, by default /About does not match /about. Pass { ignoreCase: true } as the last argument to the constructor to ignore case.

Should I use URLPattern to read query parameters?

Usually not. A search pattern matches the query text in order, so q=* does not match ?page=2&q=cat. Match the path with URLPattern and read the query with URLSearchParams, which does not care about order.

Why do my group values contain %20 or %C3%A9?

URLPattern returns the matched text as it appears in the URL, still percent-encoded. Call decodeURIComponent on a value when you need the readable text.

Keep reading