The details behind the README: the pattern edge cases, how rou3 differs from URLPattern, the full ordering rules, and the internals of routeToRegExp / regExpToRoute. Start with the README; come here when a pattern or a result surprises you.
As in URLPattern, a :name takes as little as it can, and a * or a regex constraint takes as much as it can (/ included for a *):
| Pattern | Path | Params |
|---|---|---|
/:a-:b |
/x-y-z |
{ a: "x", b: "y-z" } |
/:name.:ext |
/a.tar.gz |
{ name: "a", ext: "tar.gz" } |
/*-:a |
/x-y-z |
{ "0": "x-y", a: "z" } |
/*-:a |
/x/y-z |
{ "0": "x/y", a: "z" } |
/*-:x? |
/-- |
{ "0": "-" } |
/:a(\\d+):b? |
/12 |
{ a: "12" } |
A group holding only the param works the same: /*-{:x}? is /*-:x?.
The router matches such a segment in time linear in its length (before 0.12, /blog/:year-:month-:day.html on a long run of - took seconds). A regex constraint keeps its own cost and may still try its matches against the params after it (/:a(\\d+):b:c.json).
- A name starts with a letter or
_, followed by letters, digits or_([A-Za-z_][A-Za-z0-9_]*), as in URLPattern.:v2and:_0are names;:0and:1stthrow. - Any other ASCII character except
$ends a name,-included./blog/:year-:monthhas two params, and/users/:user-idis the paramuserfollowed by the text-id(write:user_idfor one param). - A non-ASCII character or
$right after a name throws (/:café,/:id$), because URLPattern reads it as part of the name. To end the name before it, escape it:/:caf\\éis the paramcaffollowed byé, and/:id\\$isidfollowed by$. - A group's
{or}also ends a name:/:a{b}?is the paramafollowed by an optionalb. A regex right after a group is a separate unnamed capture, not the param's constraint:/{:foo}(\\d+)on/a12gives{ foo: "a", 0: "12" }(the param takes as little as it can, as in URLPattern). - A name can appear only once per route:
/a/:x/:xthrows. - A
:must start a name. Write a literal colon as\\:. +and*only follow a param that fills its whole segment::id(\d+)+andpre-:x+throw.- A
?,+or*right after a param, constraint, group or*, inside a following group, throws:/{:foo}{*},/{:foo}?*,/{:foo}(x)?,/:id(\\d+){?},/*{*}. - Inside a regex constraint, group with
(?:…): a capturing group throws (/:x((a)),/:x((?<n>a))).
*
- A trailing
*is optional also when it ends one variant of a route with optional parts:/a/*/:x?and/a/*{.png}?match/a, and/a{/b/*}?matches/a/b. - Elsewhere, a
*takes one or more segments:/files/*/rawdoesn't match/files/raw.
(.*) and :name(.*) are the same as *, as in URLPattern.
/files/(.*)is/files/*, also inside a segment (/files/(.*).png). Write(.*)where a*would read as a modifier: after a param or group (/:id(\\d+)(.*),/{:name}(.*)).:name(.*)captures intonameinstead of a numbered key:/files/:path(.*)on/files/a/bgives{ path: "a/b" }. Unlike:name+, it can be empty:/files/gives{ path: "" }, and/filesgives{}.- Other regexes that can match
/((.+),(.*?),([^x]*)) stay within their segment.
**
- Over zero segments, it sets no key:
/docs/**on/docsand/docs/gives{}. An empty segment is"":/docs//gives{ "0": "", _: "" }. - At the end of a route, it matches the same paths as
*. The one difference is the capture on/docs/:*gives"",**no key. Where both are registered,*wins. - Before more of the route, it can match zero segments:
/docs/**/xmatches/docs/x(/docs/*/xdoesn't). - Followed by text in its segment, it reads as
*, as in URLPattern:/**.mdis/*.md, any path ending in.md. Anywhere else in a segment,**throws (/a**b,/a/x**): write*, or\\*\\*for a literal**.
**:name is the same as :name+: one or more segments into name (/docs/**:path). Like a :name, it needs a value, and so does each segment it takes: /docs// and /docs/a//b don't match (see empty segments).
One catch-all per route. *, **, **:name, (.*), :name(.*), and a :name+ / :name* that is not the last segment each count as one: /*/x/*, /*/**, /file-*-*.png and /:a(.*)/(.*) throw. Write /:a/x/* to capture one segment and the rest. A modifier on a catch-all throws too (*?, (.*)?, :path(.*)?).
Numbered keys. Unnamed captures (*, ** and (regex)) are numbered in pattern order, as in URLPattern. A capture in an optional group still takes its number when the group is left out:
/(\\d+)/x/**on/1/x/b/cgives{ "0": "1", "1": "b/c", _: "b/c" }./a{/(\\d+)}?/*on/a/x/ygives{ "1": "x/y" }.
The deprecated _. It is the name rou3 0.11 and older used for a bare **'s capture, kept so params._ still works. It is left out over zero segments (0.11 gave ""), it can't be a param name next to a bare ** (/:_/** throws), and routeToRegExp has no _ group.
- A group that starts with
/is part of the path, as in URLPattern:{/:lang}?/docsmatches/docsand/en/docs, and{/:lang}?matches/and/en. - A pattern that doesn't start with
/gets one:users/:idis/users/:id. So does each variant of a leading group without one:{v2}?/apimatches/v2/apiand/api. /{/:lang}?/docsstarts with an empty segment: it matches//docsand//en/docs.- Text right after a leading
{/…}?throws ({/a}?b,{/:id}?.png,{/a}?{.json}?): without the group, the route would not start with/. Follow the group with/, another{/…}group, or nothing.
. and .. segments in a pattern are resolved like new URL() resolves a path, as in URLPattern: /docs/../api/:id is /api/:id, /a/./b is /a/b, and a .. at the root stays there (/../a is /a). Percent-encoded dots count too (%2e, .%2E, %2e%2e). Escape a dot to keep the segment literal: /raw/\\.\\./etc matches /raw/../etc (lookup paths are never resolved unless you pass normalize).
URLPattern resolves each part of a pattern on its own, so next to a param its result is no longer a path (/:id/.. stays /:id/, /a/../:id becomes //:id). rou3 throws instead when:
- a
..would remove a segment that isn't plain text (/:id/..,/*/..,/a/{b}?/..); - a
.or..comes right before a segment that starts with a param, a catch-all or a regex group (/a/../:id,/a/./*); - a
.or..segment is inside a{...}group or touches one (/a{/..}?/b,/a/{..}/b,/a/..{x}?).
The literal text of a pattern is percent-encoded once, when the route is added, the same way URLPattern and new URL() encode a pathname. Lookup paths are never decoded or encoded, so pass the encoded pathname (new URL(req.url).pathname).
- Encoded: control characters, space,
",#,<,>,?,^,`,{,}, and every non-ASCII character (as UTF-8, upper-case hex:éis%C3%A9). Escaped characters too:\\?matches%3F,\\{matches%7B. - Not encoded:
%, so an existing%xxstays as written (/caf%c3%a9matches only/caf%c3%a9, not/caf%C3%A9), and other ASCII characters (/a|b[c]stays as is). - Regex constraints are not encoded: write
:x(%C3%A9), not:x(é). - A lone surrogate is encoded as U+FFFD (
%EF%BF%BD), as in URLPattern. - Tab, newline (LF) and carriage return (CR) throw, also in a regex constraint: URLPattern drops them from a pattern, so encoding them would match different paths. Write
%09,%0Aor%0D. ^is encoded, following the URL spec (as Node.js and Bun do). Deno (2.9) leaves it as is innew URL().pathname(/a^b), so on Deno, encode it before a lookup (pathname.replaceAll("^", "%5E")) if a route has a literal^.
Every API sees the encoded text: removeRoute, routeToRegExp, routeNodeKeys, the overlap helpers and the compiler. /café and /caf%C3%A9 are the same route, and so are /café-:id and /caf%C3%A9-:id. Regex constraints are not encoded, so :x(é) and :x(%C3%A9) stay different routes.
addRoute throws a rou3: error that quotes the pattern when the syntax has no clear meaning, instead of silently matching something unexpected:
- An unclosed
(or{, a nested group, or an empty group(). - A modifier in the wrong place:
*?,**+,(.*)?,:x(.*)?,:x.png?,{...}+,{...}*. - A
?after plain text (/foo?): lookup paths have no query string. Escape a literal?as\\?. - A
**in the middle of a segment (/a**b). - An invalid or repeated param name:
/:0,/:café,/:id$,/a/:x/:x. - A second catch-all.
- A
\/. - Text right after a leading
{/…}?group ({/a}?b). - A tab, newline or carriage return (write
%09,%0A,%0D). - A
.or..segment next to a param, catch-all or group (/:id/..,/a/../:id,/a{/..}?/b, see dot segments). - A character from U+FFFD to U+FFFF (used internally).
- In a regex constraint:
- a capturing group, also in a class (
/:x((a)),/:x([(a)])): use(?:…), or escape a class paren ([\\(]); - a
--or&&inside a class (/([[a-z]--a]),/:x([\\w&&b])): URLPattern reads it as a set operation (vflag), rou3's RegExp as plain chars. Escape a literal one ([a\\-\\-b]); - an anchor (
^,$), a look-around or a numbered backreference (\1). The router tests a constraint against its segment alone, whilerouteToRegExpputs it inline, where it would see the rest of the path, so the two would match different paths.
- a capturing group, also in a class (
rou3 matches HTTP request paths segment by segment in a tree. That leads to a few intentional differences:
| Feature | URLPattern | rou3 |
|---|---|---|
| Trailing slash | Part of the path (/foo/* on /foo/a/ gives { 0: "a/" }) |
One trailing slash is ignored ({ 0: "a" }) |
Trailing * (/api/*, also (.*), :name(.*)) |
Required: no match on /api |
Optional: matches /api (no key), so a middleware on /api/* covers /api. For a required one, use /api/:path+ |
| Catch-alls per route | Any number (/*/x/*) |
One (more throw): write /:a/x/* |
Modifiers on a catch-all (*?, (.*)?) |
Supported | Throw (a trailing * is optional anyway) |
Repeated groups ({...}+, {...}*) |
Supported | Throw |
Class set operations (/([[a-z]--a]), /([\\d&&[0-1]])) |
Difference / intersection (v flag) |
Throw: rou3 compiles without the v flag, where they would be plain chars. Escape a literal one ([a\\-\\-b]) |
Regexes that can match / ((.+), (.*?)) |
Can span segments | Stay within their segment ((.*) is a *). routeToRegExp spans segments, see Regular expressions |
Optional segment after a catch-all (/*/:x? on /x/y) |
The catch-all takes it: { 0: "x/y" } |
Segments after a catch-all match from the end: { 0: "x", x: "y" } |
Optional segment before a trailing * (/a/:x?/* on /a/b) |
The * takes it: { 0: "b" } (as in rou3 0.11) |
The optional segment takes it: { x: "b" } (routeToRegExp too) |
Patterns without a leading / (users/:id) |
Relative: never match a pathname | Read as /users/:id |
. and .. segments |
Resolved | Resolved in patterns (an escaped one is literal; next to a param or group they throw, see dot segments). Not resolved in input paths unless { normalize: true } |
| Case | Can be case-insensitive | Always case-sensitive |
| Input paths | Any URL; percent-encoded for you | Must start with / and be percent-encoded already (new URL().pathname); never decoded |
| Param names | Unicode identifiers | ASCII [A-Za-z_]\w*; a non-ASCII char or $ right after one throws |
| Tab, LF or CR in a pattern | Dropped (/a\tb is /ab) |
Throw: write %09, %0A or %0D |
Edge cases:
| Case | URLPattern | rou3 |
|---|---|---|
/foo/* on /foo// |
{ 0: "/" } |
{ 0: "" } (the trailing slash is ignored) |
/foo/** on /foo/ |
{ 0: "" } |
{} (zero segments). Elsewhere ** gives the same key and value (and the deprecated _) |
/a/*{/b}? on /a/x/b (optional group after a catch-all) |
{ 0: "x/b" } |
{ 0: "x" }; likewise /:a+/:b? on /x/y gives { a: "x", b: "y" } |
{/:a}?/* on /b (optional segment before a trailing *) |
{ 0: "b" } |
{ a: "b" } |
/:x?/a/* on /a/a/b (optional segment before a static one) |
{ x: "a", 0: "b" } (left to right, as routeToRegExp gives) |
{ 0: "a/b" }: a static segment beats a param, so the route without the optional one wins (** too) |
/\\.\\./bar (escaped dot segment) |
Resolved: /bar |
Literal: matches /../bar only. URLPattern can't write a literal dot segment (its input paths have none) |
{v2}?/api (leading group without /) |
Matches /api only (v2/api is relative) |
Each variant gets a /: matches /v2/api and /api |
/*{.webp}? on /a.webp (optional text after a greedy capture) |
{ 0: "a.webp" } |
{ 0: "a" }: the route with the group wins |
/*-{:x}?(y) on /a--y (group holding a param, before more of its segment) |
{ 0: "a-", 1: "y" } |
{ 0: "a", x: "-", 1: "y" }. A group holding only a param that ends its segment is that param, as in URLPattern (/*-{:x}? is /*-:x?, /a/*{:x}? is /a/*:x?) |
/:foo{x}?(.*) on /abx (optional group before more of its segment) |
{ foo: "a", 0: "bx" } |
{ foo: "ab", 0: "" }: the route with the group wins |
/{:foo}{(\\d*)}? on /a (optional group that can match nothing) |
{ foo: "a" } |
{ foo: "a", 0: "" }; routeToRegExp leaves it unset, like URLPattern |
/{:foo}?{(\\d+)}? on /1 (several optional groups in a segment) |
{ foo: "1" } |
{ 0: "1" }: each variant is a route, and a regex param beats a plain :foo; routeToRegExp gives { foo: "1" } |
/a/(\\d*), /a/*:x?, /a/*(\\d*) on /a/ (empty capture before a trailing slash) |
Match with "" |
No match: the trailing slash is ignored, so there is no segment left. Only a whole-segment * (/a/*, /a/*{.png}?) matches there. /a// matches (/a/*(\\d*): { 0: "", 1: "" }) |
The rules are in the README. Side by side:
addRoute(router, "GET", "/admin/:id", {});
addRoute(router, "GET", "/files/:path+", {});
addRoute(router, "GET", "/docs/:path*", {});
addRoute(router, "GET", "/raw/*", {});
findRoute(router, "GET", "/admin/"); // undefined (the trailing slash is ignored)
findRoute(router, "GET", "/admin//"); // undefined (an empty segment)
findRoute(router, "GET", "/files//"); // undefined
findRoute(router, "GET", "/files///"); // undefined (two empty segments)
findRoute(router, "GET", "/files/a//b"); // undefined (an empty segment inside the value)
findRoute(router, "GET", "/files/a/b/"); // params: { path: "a/b" } (the trailing slash is ignored)
findRoute(router, "GET", "/docs"); // matches, no `path`
findRoute(router, "GET", "/docs//"); // undefined
findRoute(router, "GET", "/raw"); // params: {} (a trailing `*` is optional)
findRoute(router, "GET", "/raw/"); // params: { "0": "" }
findRoute(router, "GET", "/raw//"); // params: { "0": "" } (the trailing slash is ignored)With { normalize: true }, only literal . and .. segments are resolved: percent-encoded dots (%2e, %2E%2E, .%2e) are left as they are, since lookup paths are never decoded. new URL() resolves those too (/a/%2e%2e/b is /b), so a new URL().pathname has none left.
As in new URL(), a last . or .. leaves a trailing slash: /foo/bar/.. is /foo/, which /foo/* matches ({ "0": "" }). An empty segment before it is kept: /a//. is /a//, which doesn't match /a, and //. and //x/.. are //, which doesn't match /.
The short version and the guarantee are in the README.
-
Across the tree: at each level, catch-all (
*,**) matches come first, then params (:name), then static segments. Broader and shallower routes come before more static and deeper ones. -
Routes on the same tree node (for example
/foo/:idand/foo/:id(\d+), or/foo/**,/foo/*and/foo/**:rest, see Route node keys):- Optional and unconstrained routes come before required and regex-constrained ones.
- Among catch-alls:
**, then a trailing*(it matches the same paths), then**:name(it needs a value). - Between segments that mix params and text (
/f/:name.:ext), the one with more literal text comes last, then the one with more regex constraints, then the one with fewer captures that may be empty (a*, an optional:x?). On/f/a.png:/f/:name.:ext,/f/:name.:ext(png|jpg),/f/:name.png, whatever the registration order. - Ties keep registration order.
- Method-agnostic routes are sorted together with the method's own routes. On a tie, the method-agnostic one comes first, so
findRoutepicks the method's own. - An optional param inside a segment (
/e/:a:b?) makes the segment one regex-constrained route. On its node it beats a plain:idsibling on every path, also on deeper routes (/e/:a:b?/xbeats/e/:id/x, though both match the same paths), and it comes before/e/:id(\d+)(a constraint ranks higher).
-
Consistent with containment: when no pattern uses optional syntax and each pattern contains the next (a
"superset"percompareRoutes), the result order is broadest first, except for the catch-all carve-out below. -
Carve-out: optional syntax. A pattern with
:name?,:name*or{...}?registers one entry per variant, and results are ordered by the variant that matched (the onefindRoutepicks, when several do), not by the whole pattern. So a broader pattern can come last:const router = createRouter(); addRoute(router, "GET", "/admin", { name: "admin" }); addRoute(router, "GET", "/admin/:page?", { name: "admin-page" }); // superset of "/admin" findAllRoutes(router, "GET", "/admin").map((m) => m.data.name); // ["admin", "admin-page"] (the broader pattern is last)
Both routes match
/adminwith an identical entry, so registration order decides: adding/admin/:page?first swaps them. A pattern is also listed by the variantfindRoutepicks when a broader one matches too:/p/*{/p}?contains/p/p/:x, but on/p/p/pit comes after it, as/p/*/p(a literal last segment ranks higher, see below), not before it as/p/*. If you need a strict pattern-level order with optional syntax, sort the result withcompareRoutes. -
Carve-out:
:name+vs. a param followed by a catch-all. No segment of a:name+,:name*or**:namecan be empty, so a route with a:nameand then a*/**over the same segments is broader (/p/:id/**also takes/p/a//b). Wildcards still come before params, so the narrower route comes first andfindRoutepicks the broader one:const router = createRouter(); addRoute(router, "GET", "/p/:id/**", { name: "id-rest" }); // superset of "/p/:path+" addRoute(router, "GET", "/p/:path+", { name: "path" }); findAllRoutes(router, "GET", "/p/a/b").map((m) => m.data.name); // ["path", "id-rest"] (the broader pattern is last)
The same holds after a catch-all (
/**/:name/xcontains/**:path/x). Sort the result withcompareRoutesif you need the strict order. -
Segments after a catch-all: a route like
/**/_payload.jsonor/*.pngis anchored at the end of the path. On every path such a route matches, all matches are ranked from the last segment backwards:- A literal segment beats a regex-constrained param, which beats a plain param or a segment covered by
**. - A segment of captures alone with at most one required
:name(*:a,:a:b?,*:x?) restricts nothing more, so it ranks as a plain param:/b/:idbeats/**/:a:b?on/b/x. - Ties fall back to the rules above.
- Paths that no such route matches are not affected:
const router = createRouter(); addRoute(router, "GET", "/blog/**", { name: "blog" }); addRoute(router, "GET", "/blog/:slug", { name: "post" }); addRoute(router, "GET", "/**/_payload.json", { name: "payload" }); addRoute(router, "GET", "/blog/:slug/_payload.json", { name: "post-payload" }); findAllRoutes(router, "GET", "/blog/_payload.json").map((m) => m.data.name); // ["blog", "post", "payload"] findAllRoutes(router, "GET", "/blog/hello/_payload.json").map((m) => m.data.name); // ["blog", "payload", "post-payload"] findRoute(router, "GET", "/blog/hello")?.data.name; // "post"
This also applies between the other routes on those paths: with
/**/_payload.jsonregistered,/:lang/_payload.jsonwins over/blog/:slugon/blog/_payload.json, because it pins the last segment. - A literal segment beats a regex-constrained param, which beats a plain param or a segment covered by
- Pass the pattern as you registered it. Only spellings the tree can't tell apart are equivalent (
/a/and/a,/**.mdand/*.md,/docs/../apiand/api):/path/*doesn't remove/path/:name, and/abdoesn't remove/a{b}. - Errors:
removeRoutethrows the samerou3:error asaddRoutefor a reserved group or modifier:{...}+/{...}*, text right after a leading{/…}?({/a}?b), a misplaced+/*(/a/pre-:x+), or a./..segment next to a param or group (/:id/..). Other invalid patterns can't have been added, so they remove nothing ({oops/x).
How routesOverlap, compareRoutes and findOverlappingRoutes (see the README) handle the harder cases:
- Answers are safe, not always exact. Whenever
compareRoutesclaims containment or disjointness, it is proven. When something can't be decided, it answers with a weaker verdict (usually"partial"), never a wrong one. - Regex constraints are checked exactly against literal segments (
/user/:id(\d+)does not overlap/user/abc). Two dynamic segments where at least one has a regex are assumed to overlap:routesOverlap("/user/:id(\d+)", "/user/:name([a-z]+)")returnstruealthough no path matches both. For the same reason, two different regexes compare as"partial", even when they are equivalent. - Segments mixing params and text are compared piece by piece:
compareRoutes("/f/:name.:ext", "/f/:name.png")andcompareRoutes("/f/:name.:ext(png|jpg)", "/f/:name.png")are"superset". - An actually equal pair that is only provable in one direction reports that containment:
/u/:id(42)vs/u/42is"superset". - Segment counts:
**and a trailing*: zero or more segments (so/a/**overlaps/a).**:name: one or more, none of them empty (compareRoutes("/a/**", "/a/**:x")is"superset", and so iscompareRoutes("/a/:x/**", "/a/:y+")).- A
*elsewhere: one or more. :name: exactly one.- Segments after a
**are aligned to the end of the path:compareRoutes("/**/_payload.json", "/blog/:slug/_payload.json")is"superset".
- Optional syntax: a pattern with
:x?,:x*or{...}?expands into several variants, and two patterns overlap when any pair of variants does. A?param after a capture in its segment (/a/*-:x?) is one regex instead, compared piece by piece ("superset"of/a/*-:x). - In
findOverlappingRoutes, different routes (another pattern or method) are always reported separately, even when they share the samedata. Registering the same route twice with the samedatareports it once. A route with segments after**comes right after the bare**it follows.
- A constraint that can match
/(:x(.+),:x([^.]+)) can span several segments in the regex (/a/:x(.+)matches/a/b/c), while the router splits the path into segments first. A(.*)/:x(.*)is a*, matched exactly. - The empty path
"": the router treats it as/(with no trailing slash for a*:/*doesn't match it). The regex matches it only for root routes whose first segment is optional (/**,/:x*,/:x?), not for/itself. - In PCRE and Perl,
$also matches before a final\n, so there the regex also matches<path>\n. .and..in the path are not resolved (in the pattern they are, as inaddRoute). Normalize the path first if your router usesnormalize: true.
Backtracking. Params that share a segment are lazy, so a naive regex retries every split of the segment on a path that fails after it (/:a-:b-:c would be cubic in the segment's length, the class of path-to-regexp's CVE-2024-45296). Where the split can be pinned down without look-arounds, the regex spells it out, with the same matches and captures: a param followed by a separator and another param ends at the separator's first occurrence (/:a-:b is ^\/(?<a>[^/][^/-]*)-(?<b>[^/]+?)\/?$, also for separators like -to-, -- or %20), a param before an optional {.:ext}? ending its segment at the first separator with text after it, and the last param after a greedy * and same-char separators holds no separator but at its ends (/*.:ext is ^\/(?<_0>[\s\S]*)\.(?<ext>[^/.]*[^/])\/?$). These fail any path in linear time. A few shapes still backtrack, quadratically in the length of a long failing segment (on Node.js 24, about 50 to 200 ms for a 16 KB one, four times that for 32 KB): a param right before a constraint (/:a-:b(\d+)-:c), separators of several kinds or chars after a * (/*-:a.:b, /*-to-:a), a lazy * before params (/a{/*-:a}?), a constraint that can match / (/:a(.+)-:b), and the look-ahead forms below. Separators that repeat their first char inside (/:a-x-x-:b-x-x-:c) keep the lazy form, polynomial in the number of params. Cap the path length where that matters (most servers already do).
Params. Unnamed captures are named _0, _1, … in the regex ("0", "1", … in the router). A regex can't capture one group under two names, so there is no group for the router's deprecated _ alias of a bare **: read the numbered one. A **:name / :name+ / :name* is [^/]+(?:\/[^/]+)*, as in URLPattern. When optional segments meet a constrained optional, or several optional segments follow a catch-all, the regex can assign a segment to a different param than the router (/a/:x?/:y(\d+)? on /a/1 sets x, the router sets y). The set of matched paths is still the same.
Errors. Patterns that addRoute rejects throw the same error, and so does a route that declares the same param name twice (/files/:path/**:path; a bare ** takes the name _, its deprecated alias).
The trailing-slash rule (at most one trailing slash, exactly one when the last segment is empty) is built into the end of each regex:
| Route | Regex |
|---|---|
/users/:id(\d+) |
^\/users\/(?<id>\d+)\/?$ |
/path/:id? |
^\/path(?:\/(?<id>[^/]+))?\/?$ |
/path/:param |
^\/path\/(?<param>[^/]+)\/?$ |
/users/:id/:tab? |
^\/users\/(?<id>[^/]+)(?:\/(?<tab>[^/]+))?\/?$ |
/path/* |
^\/path(?:\/(?<_0>(?:[\s\S]*[^/])?\/*?))?\/?$ |
/path/*.png |
^\/path\/(?<_0>[\s\S]*)\.png\/?$ |
/path/** |
^\/path(?:\/(?<_0>(?:[\s\S]*[^/])?\/*?))??\/?$ |
/base/**:path |
^\/base\/(?<path>[^/]+(?:\/[^/]+)*)\/?$ |
/** |
^(?:\/(?<_0>(?:[\s\S]*[^/])?\/*?))??\/?$ |
/**/_payload.json |
^(?:\/(?<_0>[\s\S]*))?\/_payload\.json\/?$ |
/path/**/suffix |
^\/path(?:\/(?<_0>[\s\S]*))?\/suffix\/?$ |
/**/:file |
^(?:\/(?<_0>[\s\S]*))?\/(?<file>[^/]+)\/?$ |
/a/**/:page? |
^\/a(?:\/(?<_0>[\s\S]*?))??(?:\/(?<page>[^/]+))?\/?$ |
/ |
^\/$ |
/path/:id(\d*) (look-behind) |
^\/path\/(?<id>\d*)(?:(?<=\/)\/|(?<!\/)\/?)$ |
Look-behind. The suffix (?:(?<=\/)\/|(?<!\/)\/?)$ is only used when the end of the path can't be decided otherwise, for example:
- a constraint at the end of the route whose match can end in
/(:name([^.]+),:x(.+)), - a required last segment whose constraint can match empty (
/path/:id(\d*)), - optional segments side by side where an earlier one can be empty (
/a/:x(\d*)?/:y?), - a catch-all that can be empty combined with optional segments (
/a/x-*/:page?,/a//**/:n(\d+)?,/a/**/:y?{/b}?; a**:restcan't end in/, so/a/**:rest/:page?needs none).
Fixed-length look-behinds work in JavaScript, PCRE and Perl, but not in RE2-family engines.
Optional groups. A single optional group that ends a segment is compiled inline as (?:...)?, so each param appears once:
routeToRegExp("/blog/:id(\\d+){-:title}?");
// /^\/blog\/(?<id>\d+)(?:-(?<title>[^/]+?))?\/?$/
routeToRegExp("/files/:name{.:ext}?");
// /^\/files\/(?<name>[^/][^/.]*\.??)(?:\.(?<ext>[^/]+?))?\/?$/
routeToRegExp("/users{/:id}?/posts/:post");
// /^\/users(?:\/(?<id>[^/]+))?\/posts\/(?<post>[^/]+)\/?$/- A param in a segment with other text is lazy (
[^/]+?), as in the router, so/files/:name{.:ext}?splitsarchive.tar.gzintoname: "archive"andext: "tar.gz". - An optional param in a segment is compiled in place, as in the router:
/pre-:x?is^\/pre-(?:(?<x>[^/]+?))?\/?$, and/*-:x?is^\/(?<_0>[\s\S]*)-(?:(?<x>[^/][^/-]*))?\/?$(see "Backtracking" above). - An optional group that starts its segment before more of it is inlined too:
/x/{:id}?(\\d+)is^\/x\/(?:(?<id>[^/]+?))?(?<_0>\d+)\/?$. - When the group follows a regex constraint in its segment (
/blog/:id(\d+){-:title}?), the constraint is kept from taking the group's text, with a look-ahead where needed (RE2-family engines reject it). After a*(/files/*{.:ext}?/raw), the regex is an alternation.
Duplicate named groups. Other optional combinations compile to an alternation that repeats a named group:
- several optional groups;
- an in-segment group followed by optional segments (
/:x{.:e}?/:y?); - an optional group after a capture that could take its text (
/media/*{.webp}?,/*-x{-x}?,/files/*{.:ext}?/raw); - an optional group before more of its segment that holds a capture or
*, or is followed by a bare:name(/{x(y)}?(\\d+),/{*-}?(\\d+),/{:foo}?:bar); - a group right after a catch-all (
/a/**{.png}?); - a
*before optional segments where the segment before it can be empty (/a//*/:y?).
Duplicate named groups work in JavaScript engines that support them (Node.js 23+, Chrome 125+, Firefox 129+, Safari 17+) and in Perl, and need PCRE2_DUPNAMES in strict PCRE2 engines. On Node.js 22, routeToRegExp throws a rou3: SyntaxError for these routes, and regExpToRoute can't convert them back.
Catch-all with optional segments. With one optional segment right after a **, the regex picks the same route as the router (/a/**/:n(\d+)? gives /a/b/1 to n). With several, it matches the same paths but may assign segments differently (/docs/**/:page?/:lang(en|fr)? on /docs/en sets page, the router sets lang).
- The dialect
routeToRegExpemits:(?<name>...)groups,[^/]+?params inside a segment (and their linear forms, such as[^/][^/-]*, whererouteToRegExpemits them),[\s\S]*catch-alls (an unnamed one in a segment of its own or next to text is a*),(?:/...)?optional groups and the endings shown above. Unnamed groups such as(\d+)work too, and the regex inside a constraint is kept verbatim. - Looser forms, as older versions emitted them: a plain
\/?ending,.*/.+catch-alls, and[^/]+params, read as:name.- A
:nameinside a segment is lazy, so a hand-written greedy(?<a>[^/]+)-(?<b>[^/]+)comes back as/:a-:b, which splits/x-y-zasxandy-z. - An old catch-all inside an optional group is the exception: 0.9.2's regex for
/a{/:w*}?throws, and its regex for/a{/:w+}?comes back as/a/:w(.+)?(the current regexes for both come back as/a/:w*, the same route). - A group right after a
:namegives the name its own regex, so it isn't read as the name's constraint:(?<a>[^/]+?)(?<_0>\d+)(the regex for/{:a}(\\d+)) comes back as/:a([^\\x2f]+?)(\\d+), the same route. - A catch-all group named
_(how 0.11 and older emitted**) still reads as**. - An unnamed
[^/]*(how 0.11 and older emitted a single-segment*) reads as the constraint([^\x2f]*), which matches the same paths: 0.11's regex for/foo/*comes back as/foo{/([^\x2f]*)}?, and for/*as the leading group{/([^\x2f]*)}?. - Captures follow the router, which ranks a constraint above a plain
:x: 0.11's regex for/:x?/*gives{ x: "a" }on/a, while the route it comes back as (/:x?{/([^\x2f]*)}?) gives{ "0": "a" }. - It throws where no route matches the same paths: a hand-written trailing
\/([^/]*)\/?$(it matches""on/foo/), and 0.11's*after an empty segment (/a//*).
- A
- Routes that compile to the same regex come back in one spelling:
/base/**:pathbecomes/base/:path+,/**.mdbecomes/*.md,/a{/*}?becomes/a/**,/a/pre-{:x}?becomes/a/pre-:x?,/a{/:x+}?becomes/a/:x*,/a{/**}?becomes/a/**(0.11 read its regex as/a/:_*),/a{/([\s\S]*)}?becomes/a/**, and/a/:x?/:y?becomes/a{/:x/:y?}?. Percent-encoded text stays encoded: the regex for/cafécomes back as/caf%C3%A9(the same route).
It throws for:
- a regex not anchored with both
^and$; - look-arounds and backreferences;
- regex operators outside a constraint (
|,.,+,[…], …); - a literal character outside a constraint that a route would percent-encode (
é, a space,\{,\?, …), since no route matches it; - the flags
i,m,s,uandv(g,yanddare ignored); - the duplicate-group alternations above;
- constraints that can't be written as a route (for example one containing
/).