[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"verticals":3,"quiz-urlpattern-native-route-matching":44,"search-suggestions":60,"quiz-article-urlpattern-native-route-matching":106},[4,20,32],{"id":5,"slug":6,"name":7,"tagline":8,"description":9,"accentFrom":10,"accentTo":11,"icon":12,"defaultLocale":13,"locales":14,"features":16,"position":19},"019fe637-3d33-714b-b57f-23e163ffca0c","dev","Web Development","Read it. Run it. Prove it.","A post a day on modern web development — most with an editable playground and a quiz that explains every answer. Free, no account needed.","violet-500","cyan-400","◇","en",[13,15],"fa",{"courses":17,"paths":17,"articles":17,"exams":18,"flashcards":18,"packages":17,"community":17,"certificates":17,"teams":17,"commerce":17},true,false,0,{"id":21,"slug":22,"name":23,"tagline":24,"description":25,"accentFrom":26,"accentTo":10,"icon":27,"defaultLocale":13,"locales":28,"features":30,"position":31},"019fe637-3dc2-754c-8657-0f175bfee7c6","lang","Languages","Learn a language the way you learn a codebase.","Grammar explained the way good documentation explains an API — one idea at a time, each with a quiz.","amber-400","⌘",[13,15,29],"es",{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":17,"packages":18,"community":17,"certificates":17,"teams":18,"commerce":18},2,{"id":33,"slug":34,"name":35,"tagline":36,"description":37,"accentFrom":38,"accentTo":39,"icon":40,"defaultLocale":13,"locales":41,"features":42,"position":43},"7b3c16f2-931d-410e-802e-e1fa4edab7de","soft","Soft Skills","The half of the job nobody wrote documentation for.","Weekly, on the parts of working life that decide more than your code does — first weeks, meetings, interviews, promotions, and the people around you. Written from what actually happens, and recorded as a podcast you can listen to on the walk.","emerald-400","teal-300","◉",[13],{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":18,"packages":18,"community":17,"certificates":18,"teams":18,"commerce":18},3,{"id":45,"slug":46,"kind":47,"title":48,"description":49,"config":50,"verticalId":5,"vertical":55,"course":52,"_count":56,"access":57,"attempts":59,"questionCount":51},"01a00952-9dcb-7138-b8b7-b818b267ea1d","urlpattern-native-route-matching","PRACTICE_QUIZ","URLPattern: native route matching","Check what you picked up about URLPattern — the browser's built-in route matcher — including its default matching behavior, pattern syntax, and where it stops being the right tool.",{"questionCount":51,"timeLimitSec":52,"shuffleQuestions":18,"shuffleOptions":17,"negativeMarking":19,"passScorePct":53,"maxAttempts":52,"revealAnswers":54,"allowFlagging":18,"allowBacktracking":17},7,null,70,"AFTER_SUBMIT",{"slug":6,"name":7},{"questions":51},{"allowed":17,"reason":58},"FREE",[],[61,65,69,73,77,81,85,89,93,97,100,103],{"slug":62,"name":63,"articles":64},"webdev","Webdev",59,{"slug":66,"name":67,"articles":68},"frontend","Frontend",53,{"slug":70,"name":71,"articles":72},"javascript","Javascript",51,{"slug":74,"name":75,"articles":76},"css","Css",23,{"slug":78,"name":79,"articles":80},"typescript","Typescript",10,{"slug":82,"name":83,"articles":84},"performance","Performance",8,{"slug":86,"name":87,"articles":88},"grammar","Grammar",6,{"slug":90,"name":91,"articles":92},"react","React",5,{"slug":94,"name":95,"articles":96},"tutorial","Tutorial",4,{"slug":98,"name":99,"articles":96},"node","Node",{"slug":101,"name":102,"articles":43},"ai","Ai",{"slug":104,"name":105,"articles":43},"html","Html",{"id":107,"slug":46,"title":108,"subtitle":52,"excerpt":109,"coverUrl":110,"locale":13,"readingMinutes":88,"publishedAt":111,"viewCount":112,"likeCount":19,"commentCount":19,"author":113,"vertical":118,"topic":119,"tags":121,"_count":128,"playground":130,"body":132,"bodyMd":296,"seo":297,"translationGroupId":300,"thread":301,"assessments":303,"translations":306,"quiz":308},"01a00952-9d58-7601-89ac-42fc7bf00fc9","Stop Writing Regex to Match URLs — The Browser Already Can","A service worker's route regex was missing one anchor and started caching live edit forms as if they were read-only pages. URLPattern, native since 2021, doesn't let you make that mistake.","\u002Fmedia\u002Fcovers\u002Furlpattern-native-route-matching.png","2026-08-20T06:53:07.359Z",52,{"id":114,"name":115,"username":116,"avatarUrl":52,"headline":117},"019fe637-3c25-7088-9034-39c9f15dc3c8","Parsa Jiravand","parsa","Frontend engineer · building bestpractic",{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":70,"name":120},"JavaScript",[122,123,124,127],{"slug":70,"name":71,"color":52},{"slug":62,"name":63,"color":52},{"slug":125,"name":126,"color":52},"browser","Browser",{"slug":66,"name":67,"color":52},{"assessments":129},1,{"slug":46,"title":131},"URLPattern route matcher — interactive playground",{"blocks":133,"version":129},[134,138,141,144,150,153,158,161,164,168,171,174,177,180,184,187,190,193,196,199,203,206,209,212,216,219,222,225,228,231,234,237,240,244,247,251,254,257,260,263,266,269,272,275,278,281,284,287],{"id":135,"html":136,"type":137},"b1","\u003Cp>Priya was three paragraphs into rewriting a support ticket when the page flashed and her draft reverted to what it had looked like an hour earlier. She hadn&#39;t refreshed. Nobody had.\u003C\u002Fp>","paragraph",{"id":139,"html":140,"type":137},"b2","\u003Cp>The service worker had.\u003C\u002Fp>",{"id":142,"html":143,"type":137},"b3","\u003Cp>It was running a cache-first strategy for ticket pages — fetch once, serve from cache after that, so the dashboard felt instant on a flaky connection. The intent was to cache \u003Ccode>\u002Ftickets\u002F482\u003C\u002Fcode>, the read-only view, and leave \u003Ccode>\u002Ftickets\u002F482\u002Fedit\u003C\u002Fcode> alone, since an edit form is exactly the page you never want served stale. Here&#39;s the line that decided which was which:\u003C\u002Fp>",{"id":145,"code":146,"type":147,"language":148,"highlight":149},"b4","const isTicketView = \u002F^\\\u002Ftickets\\\u002F\\d+\u002F.test(pathname);","code","js",[],{"id":151,"html":152,"type":137},"b5","\u003Cp>Spot it yet? Read it once more before you scroll.\u003C\u002Fp>",{"id":154,"html":155,"text":156,"type":157,"level":31},"b6","The missing character was \u003Ccode>$\u003C\u002Fcode>","The missing character was $","heading",{"id":159,"html":160,"type":137},"b7","\u003Cp>\u003Ccode>\u002F^\\\u002Ftickets\\\u002F\\d+\u002F\u003C\u002Fcode> anchors the \u003Cem>start\u003C\u002Fem> of the string — \u003Ccode>^\u003C\u002Fcode> — but never anchors the end. So it matches \u003Ccode>\u002Ftickets\u002F482\u003C\u002Fcode>. It also matches \u003Ccode>\u002Ftickets\u002F482\u002Fedit\u003C\u002Fcode>, \u003Ccode>\u002Ftickets\u002F482\u002Fhistory\u003C\u002Fcode>, and \u003Ccode>\u002Ftickets\u002F482-anything-at-all\u003C\u002Fcode>, because &quot;one or more digits after \u003Ccode>\u002Ftickets\u002F\u003C\u002Fcode>&quot; is true of all of them. The regex was never wrong about what it checked. It just never checked enough.\u003C\u002Fp>",{"id":162,"html":163,"type":137},"b8","\u003Cp>The one-character fix is obvious once you see it:\u003C\u002Fp>",{"id":165,"code":166,"type":147,"language":148,"highlight":167},"b9","const isTicketView = \u002F^\\\u002Ftickets\\\u002F\\d+$\u002F.test(pathname);",[],{"id":169,"html":170,"type":137},"b10","\u003Cp>Ship that and you&#39;ll hit the next edge case within a week: a trailing slash (\u003Ccode>\u002Ftickets\u002F482\u002F\u003C\u002Fcode>) now fails to match, because \u003Ccode>$\u003C\u002Fcode> demands nothing comes after the digits — not even a slash. Add \u003Ccode>\\\u002F?\u003C\u002Fcode> before the \u003Ccode>$\u003C\u002Fcode> and you&#39;ve fixed that one. Then someone deep-links to \u003Ccode>\u002Ftickets\u002F482?tab=history\u003C\u002Fcode> and the query string breaks the anchor again, because \u003Ccode>pathname\u003C\u002Fcode> on some code paths actually holds the full URL. Each fix is a patch on the last, and every patch is a chance to reintroduce the first bug in a new shape.\u003C\u002Fp>",{"id":172,"html":173,"type":137},"b11","\u003Cp>This is the part nobody tells you about hand-rolled URL matching: it isn&#39;t hard because regex is hard. It&#39;s hard because &quot;does this path match this shape&quot; has a dozen boundary conditions, and a hand-written pattern only encodes the ones you happened to think of on the day you wrote it.\u003C\u002Fp>",{"id":175,"html":176,"text":176,"type":157,"level":31},"b12","The API built for exactly this job",{"id":178,"html":179,"type":137},"b13","\u003Cp>The browser has had a purpose-built answer since 2021, and it isn&#39;t a regex — it&#39;s \u003Ca href=\"https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FURLPattern\">\u003Ccode>URLPattern\u003C\u002Fcode>\u003C\u002Fa>, a global constructor that matches and parses URLs the way a router does, natively:\u003C\u002Fp>",{"id":181,"code":182,"type":147,"language":148,"highlight":183},"b14","const ticketView = new URLPattern({ pathname: \"\u002Ftickets\u002F:id\" });\n\nticketView.test({ pathname: \"\u002Ftickets\u002F482\" });        \u002F\u002F true\nticketView.test({ pathname: \"\u002Ftickets\u002F482\u002Fedit\" });   \u002F\u002F false",[],{"id":185,"html":186,"type":137},"b15","\u003Cp>No \u003Ccode>^\u003C\u002Fcode>, no \u003Ccode>$\u003C\u002Fcode>, no \u003Ccode>\\d+\u003C\u002Fcode>. \u003Ccode>\u002Ftickets\u002F:id\u003C\u002Fcode> matches the \u003Cem>whole\u003C\u002Fem> pathname component by default — that&#39;s not a lucky accident of this example, it&#39;s the core design decision. A \u003Ccode>URLPattern\u003C\u002Fcode> pattern is exact-match unless you explicitly tell it otherwise. There&#39;s no anchor to forget, because there&#39;s nothing unanchored to begin with.\u003C\u002Fp>",{"id":188,"html":189,"type":137},"b16","\u003Cp>That was invented for this exact scenario, by the way — it originally shipped to give service workers a real routing syntax for \u003Ccode>fetch\u003C\u002Fcode> event handlers instead of everyone writing their own regex. You&#39;re not reaching for an obscure API here; you&#39;re reaching for the one built by people who&#39;d already hit Priya&#39;s bug.\u003C\u002Fp>",{"id":191,"html":192,"text":192,"type":157,"level":31},"b17","Reading the pattern syntax",{"id":194,"html":195,"type":137},"b18","\u003Cp>\u003Ccode>:id\u003C\u002Fcode> is a \u003Cem>named group\u003C\u002Fem> — it captures a path segment and gives it a name you can read back later. That&#39;s the syntax doing double duty: matching and extracting, in one string.\u003C\u002Fp>",{"id":197,"html":198,"type":137},"b19","\u003Cp>Three more pieces cover almost everything else you&#39;ll need:\u003C\u002Fp>",{"id":200,"code":201,"type":147,"language":148,"highlight":202},"b20","\u002F\u002F Wildcard — matches anything, including further slashes\nnew URLPattern({ pathname: \"\u002Ffiles\u002F*\" }).test({ pathname: \"\u002Ffiles\u002F2026\u002Fq3\u002Freport.pdf\" }); \u002F\u002F true\n\n\u002F\u002F Optional group — the {…} makes a whole segment, slash included, optional\nnew URLPattern({ pathname: \"\u002Fblog{\u002F:year}?\" }).test({ pathname: \"\u002Fblog\" });       \u002F\u002F true\nnew URLPattern({ pathname: \"\u002Fblog{\u002F:year}?\" }).test({ pathname: \"\u002Fblog\u002F2026\" });  \u002F\u002F true\n\n\u002F\u002F Custom regex inside a named group — for when a plain segment isn't specific enough\nnew URLPattern({ pathname: \"\u002F:kind(ticket|comment)\u002F:id\" }).test({ pathname: \"\u002Fcomment\u002F482\" }); \u002F\u002F true",[],{"id":204,"html":205,"type":137},"b21","\u003Cp>Four building blocks — a literal, \u003Ccode>:name\u003C\u002Fcode>, \u003Ccode>*\u003C\u002Fcode>, and \u003Ccode>{…}?\u003C\u002Fcode> — cover the shapes that used to take a paragraph of regex to express, and read back close to plain English.\u003C\u002Fp>",{"id":207,"html":208,"text":208,"type":157,"level":31},"b22","Pulling the params back out",{"id":210,"html":211,"type":137},"b23","\u003Cp>\u003Ccode>test()\u003C\u002Fcode> only answers yes or no. When you need the actual \u003Ccode>id\u003C\u002Fcode>, call \u003Ccode>exec()\u003C\u002Fcode> instead:\u003C\u002Fp>",{"id":213,"code":214,"type":147,"language":148,"highlight":215},"b24","const pattern = new URLPattern({ pathname: \"\u002Ftickets\u002F:id\" });\nconst match = pattern.exec({ pathname: \"\u002Ftickets\u002F482\" });\n\nmatch.pathname.groups.id; \u002F\u002F \"482\"",[],{"id":217,"html":218,"type":137},"b25","\u003Cp>\u003Ccode>exec()\u003C\u002Fcode> returns \u003Ccode>null\u003C\u002Fcode> on no match, and otherwise an object with one entry per URL component you matched against (\u003Ccode>pathname\u003C\u002Fcode>, \u003Ccode>search\u003C\u002Fcode>, \u003Ccode>hash\u003C\u002Fcode>, and so on), each carrying a \u003Ccode>groups\u003C\u002Fcode> object keyed by name. No manual \u003Ccode>.split(&quot;\u002F&quot;)\u003C\u002Fcode>, no \u003Ccode>match[1]\u003C\u002Fcode> where you have to remember what group 1 was.\u003C\u002Fp>",{"id":220,"html":221,"type":137},"b26","\u003C!-- playground:start -->",{"id":223,"html":224,"text":224,"type":157,"level":31},"b27","🎮 Try it yourself",{"id":226,"html":227,"type":137},"b28","\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Furlpattern-native-route-matching\u002Fplayground\">▶️ Open the interactive playground →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":229,"html":230,"type":137},"b29","\u003Cp>\u003Cem>Runs right in your browser — poke at it and watch the concept react live.\u003C\u002Fem>\u003C\u002Fp>",{"id":232,"html":233,"type":137},"b30","\u003C!-- playground:end -->",{"id":235,"html":236,"text":236,"type":157,"level":31},"b31","The fix, with two patterns instead of one regex",{"id":238,"html":239,"type":137},"b32","\u003Cp>Back in the service worker, the honest fix isn&#39;t a smarter regex — it&#39;s naming the two things that were always different:\u003C\u002Fp>",{"id":241,"code":242,"type":147,"language":148,"highlight":243},"b33","const ticketView = new URLPattern({ pathname: \"\u002Ftickets\u002F:id\" });\nconst ticketEdit = new URLPattern({ pathname: \"\u002Ftickets\u002F:id\u002Fedit\" });\n\nself.addEventListener(\"fetch\", (event) => {\n  const url = new URL(event.request.url);\n\n  if (ticketView.test(url) && !ticketEdit.test(url)) {\n    event.respondWith(cacheFirst(event.request));\n  }\n  \u002F\u002F ticketEdit, and everything else, falls through to the network.\n});",[],{"id":245,"html":246,"type":137},"b34","\u003Cp>\u003Ccode>ticketView\u003C\u002Fcode> never matches \u003Ccode>\u002Ftickets\u002F482\u002Fedit\u003C\u002Fcode> — not because of a character you remembered to add, but because the pattern simply describes a shorter path than the one it&#39;s being compared against. There&#39;s no anchor to audit in a future code review, because exactness was never optional.\u003C\u002Fp>",{"id":248,"html":249,"text":250,"type":157,"level":31},"b35","Where it doesn&#39;t replace anything","Where it doesn't replace anything",{"id":252,"html":253,"type":137},"b36","\u003Cp>\u003Ccode>URLPattern\u003C\u002Fcode> matches and extracts. It doesn&#39;t dispatch, and it doesn&#39;t rank overlapping routes by specificity the way a full router does — if you hand it a list of ten patterns, you&#39;re still the one deciding which to check first. For a page with dozens of nested, code-split routes, you&#39;ll still likely reach for a router library that happens to use something like this under the hood. But for the far more common case — a service worker&#39;s allowlist, an analytics filter deciding which URLs to sample, a tiny client-side router that only ever needed four routes — you don&#39;t need the library. You need the four lines above.\u003C\u002Fp>",{"id":255,"html":256,"type":137},"b37","\u003Cp>It&#39;s also worth checking your target audience before you lean on it fully: it&#39;s been in Chrome and Edge since 2021, and Firefox and Safari caught up through 2025, so by now it&#39;s safe to reach for directly rather than through a polyfill in almost any modern app.\u003C\u002Fp>",{"id":258,"html":259,"text":259,"type":157,"level":31},"b38","The one-line version",{"id":261,"html":262,"type":137},"b39","\u003Cp>The regex wasn&#39;t wrong about what it checked for — it just never had to promise it checked \u003Cem>everything\u003C\u002Fem>, and one missing \u003Ccode>$\u003C\u002Fcode> let an edit form get served like a snapshot. \u003Ccode>URLPattern\u003C\u002Fcode> doesn&#39;t make that promise implicit. Exactness is the default, not a character you have to remember.\u003C\u002Fp>",{"id":264,"html":265,"type":137},"b40","\u003Cp>Next time you catch yourself writing \u003Ccode>\u002F^\\\u002Fsomething\\\u002F\\d+\u002F\u003C\u002Fcode> and mentally listing the edge cases you&#39;ll patch in later — trailing slash, query string, that one route with two segments — stop and reach for \u003Ccode>new URLPattern({ pathname: &quot;...&quot; })\u003C\u002Fcode> instead. What&#39;s the last regex you wrote to match a URL? I&#39;d bet it&#39;s missing an anchor somewhere too.\u003C\u002Fp>",{"id":267,"html":268,"type":137},"b41","\u003C!-- quiz:start -->",{"id":270,"html":271,"text":271,"type":157,"level":31},"b42","🧠 Test yourself",{"id":273,"html":274,"type":137},"b43","\u003Cp>Think it clicked? \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Furlpattern-native-route-matching\u002Fquiz\">Take the 7-question quiz →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":276,"html":277,"type":137},"b44","\u003Cp>\u003Cem>Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.\u003C\u002Fem>\u003C\u002Fp>",{"id":279,"html":280,"type":137},"b45","\u003C!-- quiz:end -->",{"id":282,"type":283},"b46","divider",{"id":285,"html":286,"type":137},"b47","\u003Cp>\u003Cem>Thanks for reading! Let&#39;s stay connected:\u003C\u002Fem>\u003C\u002Fp>",{"id":288,"type":289,"items":290,"ordered":18},"b48","list",[291,292,293,294,295],"⭐ \u003Cstrong>GitHub\u003C\u002Fstrong> — follow me and star the projects: \u003Ca href=\"https:\u002F\u002Fgithub.com\u002Fparsajiravand\">github.com\u002Fparsajiravand\u003C\u002Fa>","💬 \u003Cstrong>Discord\u003C\u002Fstrong> — join the frontend best-practices community: \u003Ca href=\"https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ\">discord.gg\u002Fd9KRhuAwQ\u003C\u002Fa>","📸 \u003Cstrong>Instagram\u003C\u002Fstrong> — frontend best practices, daily: \u003Ca href=\"https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F\">@bestpractice___\u003C\u002Fa>","💼 \u003Cstrong>LinkedIn\u003C\u002Fstrong> — \u003Ca href=\"https:\u002F\u002Fwww.linkedin.com\u002Fin\u002Fparsa-jiravand\u002F\">linkedin.com\u002Fin\u002Fparsa-jiravand\u003C\u002Fa>","✉️ \u003Cstrong>Email\u003C\u002Fstrong> (work &amp; contract inquiries): \u003Ca href=\"mailto:bestpractice2026@gmail.com\">bestpractice2026@gmail.com\u003C\u002Fa>","Priya was three paragraphs into rewriting a support ticket when the page flashed and her draft reverted to what it had looked like an hour earlier. She hadn't refreshed. Nobody had.\n\nThe service worker had.\n\nIt was running a cache-first strategy for ticket pages — fetch once, serve from cache after that, so the dashboard felt instant on a flaky connection. The intent was to cache `\u002Ftickets\u002F482`, the read-only view, and leave `\u002Ftickets\u002F482\u002Fedit` alone, since an edit form is exactly the page you never want served stale. Here's the line that decided which was which:\n\n```js\nconst isTicketView = \u002F^\\\u002Ftickets\\\u002F\\d+\u002F.test(pathname);\n```\n\nSpot it yet? Read it once more before you scroll.\n\n## The missing character was `$`\n\n`\u002F^\\\u002Ftickets\\\u002F\\d+\u002F` anchors the *start* of the string — `^` — but never anchors the end. So it matches `\u002Ftickets\u002F482`. It also matches `\u002Ftickets\u002F482\u002Fedit`, `\u002Ftickets\u002F482\u002Fhistory`, and `\u002Ftickets\u002F482-anything-at-all`, because \"one or more digits after `\u002Ftickets\u002F`\" is true of all of them. The regex was never wrong about what it checked. It just never checked enough.\n\nThe one-character fix is obvious once you see it:\n\n```js\nconst isTicketView = \u002F^\\\u002Ftickets\\\u002F\\d+$\u002F.test(pathname);\n```\n\nShip that and you'll hit the next edge case within a week: a trailing slash (`\u002Ftickets\u002F482\u002F`) now fails to match, because `$` demands nothing comes after the digits — not even a slash. Add `\\\u002F?` before the `$` and you've fixed that one. Then someone deep-links to `\u002Ftickets\u002F482?tab=history` and the query string breaks the anchor again, because `pathname` on some code paths actually holds the full URL. Each fix is a patch on the last, and every patch is a chance to reintroduce the first bug in a new shape.\n\nThis is the part nobody tells you about hand-rolled URL matching: it isn't hard because regex is hard. It's hard because \"does this path match this shape\" has a dozen boundary conditions, and a hand-written pattern only encodes the ones you happened to think of on the day you wrote it.\n\n## The API built for exactly this job\n\nThe browser has had a purpose-built answer since 2021, and it isn't a regex — it's [`URLPattern`](https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FURLPattern), a global constructor that matches and parses URLs the way a router does, natively:\n\n```js\nconst ticketView = new URLPattern({ pathname: \"\u002Ftickets\u002F:id\" });\n\nticketView.test({ pathname: \"\u002Ftickets\u002F482\" });        \u002F\u002F true\nticketView.test({ pathname: \"\u002Ftickets\u002F482\u002Fedit\" });   \u002F\u002F false\n```\n\nNo `^`, no `$`, no `\\d+`. `\u002Ftickets\u002F:id` matches the *whole* pathname component by default — that's not a lucky accident of this example, it's the core design decision. A `URLPattern` pattern is exact-match unless you explicitly tell it otherwise. There's no anchor to forget, because there's nothing unanchored to begin with.\n\nThat was invented for this exact scenario, by the way — it originally shipped to give service workers a real routing syntax for `fetch` event handlers instead of everyone writing their own regex. You're not reaching for an obscure API here; you're reaching for the one built by people who'd already hit Priya's bug.\n\n## Reading the pattern syntax\n\n`:id` is a *named group* — it captures a path segment and gives it a name you can read back later. That's the syntax doing double duty: matching and extracting, in one string.\n\nThree more pieces cover almost everything else you'll need:\n\n```js\n\u002F\u002F Wildcard — matches anything, including further slashes\nnew URLPattern({ pathname: \"\u002Ffiles\u002F*\" }).test({ pathname: \"\u002Ffiles\u002F2026\u002Fq3\u002Freport.pdf\" }); \u002F\u002F true\n\n\u002F\u002F Optional group — the {…} makes a whole segment, slash included, optional\nnew URLPattern({ pathname: \"\u002Fblog{\u002F:year}?\" }).test({ pathname: \"\u002Fblog\" });       \u002F\u002F true\nnew URLPattern({ pathname: \"\u002Fblog{\u002F:year}?\" }).test({ pathname: \"\u002Fblog\u002F2026\" });  \u002F\u002F true\n\n\u002F\u002F Custom regex inside a named group — for when a plain segment isn't specific enough\nnew URLPattern({ pathname: \"\u002F:kind(ticket|comment)\u002F:id\" }).test({ pathname: \"\u002Fcomment\u002F482\" }); \u002F\u002F true\n```\n\nFour building blocks — a literal, `:name`, `*`, and `{…}?` — cover the shapes that used to take a paragraph of regex to express, and read back close to plain English.\n\n## Pulling the params back out\n\n`test()` only answers yes or no. When you need the actual `id`, call `exec()` instead:\n\n```js\nconst pattern = new URLPattern({ pathname: \"\u002Ftickets\u002F:id\" });\nconst match = pattern.exec({ pathname: \"\u002Ftickets\u002F482\" });\n\nmatch.pathname.groups.id; \u002F\u002F \"482\"\n```\n\n`exec()` returns `null` on no match, and otherwise an object with one entry per URL component you matched against (`pathname`, `search`, `hash`, and so on), each carrying a `groups` object keyed by name. No manual `.split(\"\u002F\")`, no `match[1]` where you have to remember what group 1 was.\n\n\u003C!-- playground:start -->\n\n## 🎮 Try it yourself\n\n**[▶️ Open the interactive playground →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Furlpattern-native-route-matching\u002Fplayground)**\n\n_Runs right in your browser — poke at it and watch the concept react live._\n\n\u003C!-- playground:end -->\n\n## The fix, with two patterns instead of one regex\n\nBack in the service worker, the honest fix isn't a smarter regex — it's naming the two things that were always different:\n\n```js\nconst ticketView = new URLPattern({ pathname: \"\u002Ftickets\u002F:id\" });\nconst ticketEdit = new URLPattern({ pathname: \"\u002Ftickets\u002F:id\u002Fedit\" });\n\nself.addEventListener(\"fetch\", (event) => {\n  const url = new URL(event.request.url);\n\n  if (ticketView.test(url) && !ticketEdit.test(url)) {\n    event.respondWith(cacheFirst(event.request));\n  }\n  \u002F\u002F ticketEdit, and everything else, falls through to the network.\n});\n```\n\n`ticketView` never matches `\u002Ftickets\u002F482\u002Fedit` — not because of a character you remembered to add, but because the pattern simply describes a shorter path than the one it's being compared against. There's no anchor to audit in a future code review, because exactness was never optional.\n\n## Where it doesn't replace anything\n\n`URLPattern` matches and extracts. It doesn't dispatch, and it doesn't rank overlapping routes by specificity the way a full router does — if you hand it a list of ten patterns, you're still the one deciding which to check first. For a page with dozens of nested, code-split routes, you'll still likely reach for a router library that happens to use something like this under the hood. But for the far more common case — a service worker's allowlist, an analytics filter deciding which URLs to sample, a tiny client-side router that only ever needed four routes — you don't need the library. You need the four lines above.\n\nIt's also worth checking your target audience before you lean on it fully: it's been in Chrome and Edge since 2021, and Firefox and Safari caught up through 2025, so by now it's safe to reach for directly rather than through a polyfill in almost any modern app.\n\n## The one-line version\n\nThe regex wasn't wrong about what it checked for — it just never had to promise it checked *everything*, and one missing `$` let an edit form get served like a snapshot. `URLPattern` doesn't make that promise implicit. Exactness is the default, not a character you have to remember.\n\nNext time you catch yourself writing `\u002F^\\\u002Fsomething\\\u002F\\d+\u002F` and mentally listing the edge cases you'll patch in later — trailing slash, query string, that one route with two segments — stop and reach for `new URLPattern({ pathname: \"...\" })` instead. What's the last regex you wrote to match a URL? I'd bet it's missing an anchor somewhere too.\n\n\u003C!-- quiz:start -->\n\n## 🧠 Test yourself\n\nThink it clicked? **[Take the 7-question quiz →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Furlpattern-native-route-matching\u002Fquiz)**\n\n_Instant feedback, a hint on every question, and an explanation for each answer — right or wrong._\n\n\u003C!-- quiz:end -->\n\n---\n\n*Thanks for reading! Let's stay connected:*\n\n- ⭐ **GitHub** — follow me and star the projects: [github.com\u002Fparsajiravand](https:\u002F\u002Fgithub.com\u002Fparsajiravand)\n- 💬 **Discord** — join the frontend best-practices community: [discord.gg\u002Fd9KRhuAwQ](https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ)\n- 📸 **Instagram** — frontend best practices, daily: [@bestpractice___](https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F)\n- 💼 **LinkedIn** — [linkedin.com\u002Fin\u002Fparsa-jiravand](https:\u002F\u002Fwww.linkedin.com\u002Fin\u002Fparsa-jiravand\u002F)\n- ✉️ **Email** (work & contract inquiries): [bestpractice2026@gmail.com](mailto:bestpractice2026@gmail.com)",{"title":108,"canonical":298,"description":299},"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Furlpattern-native-route-matching","A service worker's route regex was missing one anchor and started caching live edit forms as if they were read-only pages. URLPattern, native since 2021, doesn't let you make that ","01a00952-9d58-7601-89ac-47f055ebe7d9",{"id":302,"locked":18},"01a00952-9d96-7186-8378-02ee0479aefc",[304],{"id":45,"slug":46,"title":48,"_count":305},{"questions":51},[307],{"locale":13,"slug":46},{"id":45,"slug":46,"title":48,"_count":309,"questionCount":51},{"questions":51}]