Lightweight and fast rou(ter) for JavaScript
Lightweight and fast router for JavaScript.
Install:
# ✨ Auto-detect
npx nypm install rou3Import:
ESM (Node.js, Bun, Deno)
import {
createRouter,
addRoute,
findRoute,
removeRoute,
findAllRoutes,
routesOverlap,
compareRoutes,
findOverlappingRoutes,
routeNodeKeys,
routeToRegExp,
regExpToRoute,
NullProtoObj,
} from "rou3";CDN (Deno and Browsers)
import {
createRouter,
addRoute,
findRoute,
removeRoute,
findAllRoutes,
routesOverlap,
compareRoutes,
findOverlappingRoutes,
routeNodeKeys,
routeToRegExp,
regExpToRoute,
NullProtoObj,
} from "https://esm.sh/rou3";Create a router instance and insert routes:
import { createRouter, addRoute } from "rou3";
const router = createRouter(/* options */);
addRoute(router, "GET", "/path", { payload: "this path" });
addRoute(router, "POST", "/path/:name", { payload: "named route" });
addRoute(router, "GET", "/path/foo/**", { payload: "wildcard route" });
addRoute(router, "GET", "/path/foo/**:name", {
payload: "named wildcard route",
});Match route to access matched data:
// Returns { payload: 'this path' }
findRoute(router, "GET", "/path");
// Returns { payload: 'named route', params: { name: 'fooval' } }
findRoute(router, "POST", "/path/fooval");
// Returns { payload: 'wildcard route' }
findRoute(router, "GET", "/path/foo/bar/baz");
// Returns undefined (no route matched for/)
findRoute(router, "GET", "/");Match all routes, ordered least → most specific:
findAllRoutes(router, "GET", "/path/foo/bar/baz");
// [
// { data: { payload: "wildcard route" } },
// { data: { payload: "named wildcard route" }, params: { name: "bar/baz" } },
// ]The result ordering is a documented contract — see Result ordering.
[!IMPORTANT] Paths should always begin with
/.
[!IMPORTANT] Method should always be UPPERCASE.
[!TIP] If you need to register a pattern containing literal
:or*, you can escape them with\\. For example,/static\\:path/\\*\\*matches only the static/static:path/**route.
rou3 supports URLPattern-compatible syntax.
| Pattern | Example Match | Params |
|---|---|---|
/path/to/resource |
/path/to/resource |
{} |
/users/:name |
/users/foo |
{ name: "foo" } |
/path/** |
/path/foo/bar |
{} |
/path/**:rest |
/path/foo/bar |
{ rest: "foo/bar" } |
/files/*.png |
/files/icon.png |
{ "0": "icon" } |
/files/file-*-*.png |
/files/file-a-b.png |
{ "0": "a", "1": "b" } |
/users/:id(\\d+) |
/users/123 |
{ id: "123" } |
/files/:ext(png|jpg) |
/files/png |
{ ext: "png" } |
/path/(\\d+) |
/path/123 |
{ "0": "123" } |
/users/:id? |
/users or /users/123 |
{} or { id: "123" } |
/files/:path+ |
/files/a/b/c |
{ path: "a/b/c" } |
/files/:path* |
/files or /files/a/b |
{} or { path: "a/b" } |
/book{s}? |
/book or /books |
{} |
/blog/:id(\\d+){-:title}? |
/blog/123 or /blog/123-my-post |
{ id: "123" } or { id: "123", title: "my-post" } |
:name) match a single segment.*) capture unnamed params (0, 1, ...) and can be used as full or mid-segment tokens (for example /* or /*.png).**) match zero or more segments. Use **:name to capture.:name(regex)) restrict matching. Constrained and unconstrained params can coexist on the same node (constrained checked first).(regex)) capture into auto-indexed keys 0, 1, etc.:name? (optional), :name+ (one or more), :name* (zero or more). Can combine with regex: :id(\d+)?.{...}): supported with inline (/foo{bar}) and optional (/foo{bar}?) forms.{...}+, {...}*) are supported only within a single segment (no / inside the group body).\): escape special characters like :, *, (, ), {, } with a backslash (e.g., /static\:path matches literal /static:path).rou3 aims for URLPattern-compatible syntax but has intentional differences due to its radix-tree design:
| Feature | URLPattern | rou3 |
|---|---|---|
* (single star) |
Greedy catch-all (.*) across / |
Single-segment unnamed param ([^/]*) |
** (double star) |
Literal ** |
Catch-all wildcard (zero or more segments) |
(.*) in segment |
Greedy match across / |
Segment-scoped (does not cross /) |
{...}+ / {...}* groups |
Cross-segment group repetition | Only supported within a single segment (no / in group body) |
Path normalization (./..) |
Resolves ./.. in input paths |
Not done by default (opt-in with { normalize: true }) |
| Case sensitivity | Can be case-insensitive | Always case-sensitive |
Non-/-prefixed paths |
Supported | Paths must start with / |
| Unicode param names | Supports Unicode identifiers | Params use \w (ASCII word chars only) |
| Percent-encoding | Normalizes %xx sequences |
Does not decode percent-encoded input |
By default, findRoute and findAllRoutes do not resolve ./.. segments in input paths. If your input paths may contain relative segments, enable normalization:
findRoute(router, "GET", "/foo/bar/../baz", { normalize: true });
// Matches "/foo/baz"
findAllRoutes(router, "GET", "/foo/./bar", { normalize: true });
// Matches "/foo/bar"The compiled router also supports this via the normalize option:
const match = compileRouter(router, { normalize: true });
match("GET", "/foo/bar/../baz"); // Matches "/foo/baz"findAllRoutes returns matches ordered least → most specific: the broadest scopes first, the most specific match last. The compiled matchAll (see Compiler) returns the exact same results in the exact same order. This ordering is a contract, not incidental behavior — merge/fold-style consumers (e.g. route rules resolved by merging all matched layers, taking the last as the most specific) can rely on it, and it is pinned by tests. Any intentional change to it would be a breaking change.
const router = createRouter();
addRoute(router, "GET", "/**", { name: "catch-all" });
addRoute(router, "GET", "/api/**", { name: "api" });
addRoute(router, "GET", "/api/:v/users/:id", { name: "user" });
findAllRoutes(router, "GET", "/api/v1/users/42").map((m) => m.data.name);
// ["catch-all", "api", "user"]Precisely:
Across the tree: at each level, wildcard (**) matches are emitted first, then single-segment params (*, :name), then static segments — so wilder/shallower routes come before more-static/deeper ones.
Same-node siblings (multiple routes ending on the same dynamic node, e.g. /foo/* and /foo/:id(\d+)): ordered by ascending specificity — optional/unconstrained entries before required/regex-constrained ones — with insertion order preserved on ties.
Subsumption consistency (patterns without optional syntax): when the registered patterns use no optional syntax and are strictly ordered by containment (each a "superset" of the next per compareRoutes), the result order agrees with the subsumption order (broader first).
Carve-out — optional syntax: a pattern containing :name?, :name* or {...}? registers several entries (one per expansion), and results are ordered by the specificity of the entry that matched, not by the breadth of the whole pattern. A pattern that is a "superset" of another can therefore 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 lastIf you need a true pattern-level containment order and your patterns may use optional syntax, re-sort the (small) result array yourself with compareRoutes.
Registration order never affects the result order, except as the tiebreaker between equally specific same-node entries — which, per the carve-out above, includes expansions of optional-syntax patterns (registering /admin/:page? before /admin swaps the two results in the example above).
findOverlappingRoutes follows the same least → most specific order.
findRoute/findAllRoutes match a concrete path against registered patterns. Sometimes you instead need to reason about patterns against patterns — e.g. to resolve an "effective" merged config over a whole scope, you need to know when two patterns can match a common concrete path.
Three utilities cover this:
…routesOverlap(patternA, patternB) — returns true if the two patterns' match-sets intersect (there exists a concrete path matched by both). This is overlap, not subset containment.
compareRoutes(patternA, patternB) — classifies the relation between the two match-sets. Verdicts follow the ES2025 Set-method vocabulary (isSupersetOf/isSubsetOf/isDisjointFrom) and are directional — read them as "patternA is a … of patternB":
"disjoint" — provably no common path."equal" — provably the same paths. Param names are ignored: /a/:x equals /a/:y, /u/:id(\d+) equals /u/:x(\d+), and /a/:x? equals /a/*."superset" — patternA provably matches every path patternB matches (strict unless equality is undecidable)."subset" — the mirror image (patternA ⊆ patternB)."partial" — no containment proven; the sets may intersect.Useful for ordering patterns by specificity and detecting ambiguous pairs where "most specific match" is undefined. Every verdict's containment claims are proofs, and undecidable cases degrade to a weaker verdict, never a wrong claim: containment between two different regex constraints falls back to `
No open issues yet, or sync has not completed.