dev.club — where best developers and top companies connect.

dev.club — where best developers and top companies connect.Invite only

Request invite

🌳 rou3

npm version npm downloads bundle size codecov

Lightweight and fast router for JavaScript.

Install

# ✨ Auto-detect
npx nypm install rou3

rou3 is ESM only. It runs on Node.js 20.19+, Bun, Deno and modern browsers.

Upgrading from 0.9 or older? See the migration guide.

Quick start

import { createRouter, addRoute, findRoute, findAllRoutes, removeRoute } from "rou3";

const router = createRouter();

addRoute(router, "GET", "/about", { page: "about" });
addRoute(router, "GET", "/users/:id", { page: "user" });
addRoute(router, "GET", "/docs/**", { page: "docs" });

findRoute returns the best match, or undefined:

findRoute(router, "GET", "/about");
// { data: { page: "about" } }

findRoute(router, "GET", "/users/42");
// { data: { page: "user" }, params: { id: "42" } }

findRoute(router, "GET", "/docs/guide/intro");
// { data: { page: "docs" }, params: { "0": "guide/intro", _: "guide/intro" } }

findRoute(router, "GET", "/missing");
// undefined

findAllRoutes returns every match, from the least to the most specific:

addRoute(router, "GET", "/docs/:section/intro", { page: "intro" });

findAllRoutes(router, "GET", "/docs/guide/intro");
// [
//   { data: { page: "docs" }, params: { "0": "guide/intro", _: "guide/intro" } },
//   { data: { page: "intro" }, params: { section: "guide" } },
// ]

removeRoute removes a route by the pattern it was added with:

removeRoute(router, "GET", "/users/:id");

Route data can be anything: a handler, a config object, an id. rou3 stores it and hands it back on a match.

Route patterns

rou3 supports URLPattern-like syntax. A segment is the part of a path between two /.

Pattern Example match Params
/path/to/resource /path/to/resource {}
/users/:name /users/foo { name: "foo" }
/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" }
/files/* /files/ or /files/a/b { "0": "" } or { "0": "a/b" }
/files/*.png /files/icon.png or /files/a/icon.png { "0": "icon" } or { "0": "a/icon" }
/files/:path(.*) /files/ or /files/a/b { path: "" } or { path: "a/b" }
/path/** /path/foo/bar { "0": "foo/bar", _: "foo/bar" } (_ is deprecated)
/path/**:rest /path/foo/bar { rest: "foo/bar" }
/**/_payload.json /_payload.json or /a/b/_payload.json {} or { "0": "a/b", _: "a/b" }
/**.md /docs/intro.md { "0": "docs/intro" }
/book{s}? /book or /books {}
/blog/:id(\\d+){-:title}? /blog/123 or /blog/123-my-post { id: "123" } or { id: "123", title: "my-post" }
/files/:name.:ext /files/a.tar.gz { name: "a", ext: "tar.gz" }
/v:version? /v or /v2 {} or { version: "2" }

[!NOTE] In JavaScript strings a regex backslash is written twice: "/users/:id(\\d+)" is the pattern /users/:id(\d+).

Params

Wildcards

Syntax Matches Key Empty segments
* the rest of the path, / included "0", "1", … allowed
(.*) same as * "0", "1", … allowed
:name(.*) same as * name allowed
** zero or more segments "0", "1", … (and _) allowed
**:name (= :name+) one or more segments name not allowed

See wildcards in detail for (.*), empty segments and numbered keys in optional groups.

[!WARNING] _ is deprecated. It is the name rou3 0.11 and older used for a bare **'s capture, kept so params._ still works. Read the numbered key instead: _ will be removed in a future version.

Groups

{...} groups part of a pattern without capturing it. Add ? to make it optional:

Groups can't be nested or repeated ({...}+ and {...}* throw).

Escaping and encoding

Escape :, *, ?, +, (, ), { and } with a backslash to match them literally. Outside a regex constraint, any escaped character is literal (\\. is .), as in URLPattern.

addRoute(router, "GET", "/static\\:path/\\*\\*", {}); // matches only "/static:path/**"
addRoute(router, "GET", "/files/\\(2024\\)", {}); // matches only "/files/(2024)"

The literal text of a pattern is percent-encoded once, when the route is added, as URLPattern does. Lookup paths are never decoded or encoded, so pass the encoded pathname (new URL(req.url).pathname):

addRoute(router, "GET", "/café/:id", {});
findRoute(router, "GET", "/caf%C3%A9/1"); // { data: {}, params: { id: "1" } }
findRoute(router, "GET", "/café/1"); // undefined

Regex constraints are not encoded: write :x(%C3%A9), not :x(é). See percent-encoding for exactly what is encoded.

. and .. segments in a pattern are resolved like new URL() resolves a path: /docs/../api/:id is /api/:id (see dot segments).

Invalid patterns and URLPattern differences

addRoute throws a rou3: error that quotes the pattern when the syntax has no clear meaning (an unclosed ( or {, a misplaced modifier, a repeated param name, a second catch-all, …), instead of silently matching something unexpected. The full list is in invalid patterns.

rou3 matches HTTP request paths segment by segment in a tree, so it differs from URLPattern in a few intentional ways: one trailing slash is ignored, a trailing * is optional, a route has at most one catch-all, and matching is always case-sensitive. See differences from URLPattern for the full table and edge cases.

Matching

Methods and paths

rou3 doesn't normalize lookup input, so do it before calling findRoute:

Routes for any method

Register a route with the method "" to match every method. It's useful for things like middleware or auth checks:

addRoute(router, "", "/users/*", { auth: true }); // any method
addRoute(router, "GET", "/users/:id(\\d+)", { handler: "user" });

findAllRoutes(router, "GET", "/users/42").map((m) => m.data);
// [{ auth: true }, { handler: "user" }]

findRoute(router, "GET", "/users/42")?.data; // { handler: "user" }
findRoute(router, "GET", "/users/me")?.data; // { auth: true }
findRoute(router, "POST", "/users/42")?.data; // { auth: true }

Trailing slashes and empty segments

See the examples side by side.

Path normalization

. and .. segments in input paths are not resolved by default. If your input paths may contain them, enable normalize:

findRoute(router, "GET", "/foo/bar/../baz", { normalize: true }); // matches "/foo/baz"
findAllRoutes(router, "GET", "/foo/./bar", { normalize: true }); // matches "/foo/bar"

Only literal . and .. segments are resolved, not percent-encoded ones (see path normalization). The compiler accepts the same option: compileRouter(router, { normalize: true }).

Skipping params

If you only need the route data, pass { params: false } to skip building the params object:

findRoute(router, "GET", "/docs/guide", { params: false });
// { data: { page: "docs" } }

Result ordering

findAllRoutes returns matches from the least to the most specific, and findRoute returns the most specific one. This order is part of the public API: you can rely on it, for example to merge all matched route rules so that the most specific one wins.

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"]

The compiled matchAll function returns exactly the same results in the same order.

In short:

Each route is listed once, even when several variants of an optional pattern match the path. It gets the params findRoute would give it, and its position is that variant's. Registering the same pattern twice adds two routes, and both are listed:

const router = createRouter();
addRoute(router, "GET", "/shop/:category?/:product?", { name: "shop" });

// Both `/shop/:category` and `/shop/:product` match
findAllRoutes(router, "GET", "/shop/shoes");
// [{ data: { name: "shop" }, params: { category: "shoes" } }]

Because optional syntax is ordered by the variant that matched, a broader optional pattern can come after a narrower one. See the detailed ordering rules for the full rules and their carve-outs.

Removing routes

removeRoute(router, method, pattern) removes everything that the matching addRoute call added, including all variants of an optional pattern and duplicate registrations. Other routes are left alone, even ones that share a tree node:

addRoute(router, "GET", "/path/:id", { a: true });
addRoute(router, "GET", "/path/:name", { b: true });

removeRoute(router, "GET", "/path/:name"); // "/path/:id" is still registered

Pass the pattern as you registered it: /path/* doesn't remove /path/:name (see removing routes).

Compiler

For the fastest lookups, compile a router into a single function. The compiled function returns the same results as findRoute (or findAllRoutes with matchAll: true).

Both are imported from rou3/compiler.

compileRouter(router, opts?)

Compile the router into one fast matching function, at runtime (JIT).

IMPORTANT: compileRouter uses new Function(), which a CSP without unsafe-eval blocks. Use compileRouterToString at build time there.

The compiled function is a snapshot: routes added or removed afterwards aren't seen, so compile again after changing the router. Route data is kept by reference. It returns what findRoute returns (with matchAll: true, what findAllRoutes returns), except that params is a plain object instead of a null-prototype one.

Example:

import { createRouter, addRoute } from "rou3";
import { compileRouter } from "rou3/compiler";
const router = createRouter();
// [add some routes]
const findRoute = compileRouter(router);
const matchAll = compileRouter(router, { matchAll: true });
findRoute("GET", "/path/foo/bar");

compileRouterToString(router, opts?, legacyOpts?)

Compile the router into JavaScript code, ahead of time (for example into a build output).

The output is a self-contained expression (or a const <functionName>=…; statement): no imports, no rou3 at runtime, and no eval / new Function(), so it runs under a strict CSP. It needs ES2018 (named capture groups, object spread). Like compileRouter, it is a snapshot of the router.

IMPORTANT: The generated code is not stable across rou3 versions: generate it at build time with the installed rou3, and don't commit, patch or parse it.

IMPORTANT: Route data is emitted with JSON.stringify (toJSON() applies at every depth). Data containing a function, symbol or bigint throws: pass opts.serialize to emit each route's data as your own JavaScript expression instead.

Example:

import { createRouter, addRoute } from "rou3";
import { compileRouterToString } from "rou3/compiler";
const router = createRouter();
// [add some routes with serializable data]
const compilerCode = compileRouterToString(router, { functionName: "findRoute" });
// "const findRoute=(m, p) => {}"
// Route data as code (e.g. handler imports)
compileRouterToString(router, { serialize: (data) => `{handler:${data.importName}}` });

Pattern utilities

findRoute and findAllRoutes match a path against patterns. The utilities below compare patterns against patterns, for example to check whether two route rules can apply to the same URL. They are tree-shaken away when you don't import them.

Pattern overlap

import { createRouter, addRoute, routesOverlap, compareRoutes, findOverlappingRoutes } from "rou3";

// Can the two patterns match a common path?
routesOverlap("/**", "/protected/feed/**"); // true
routesOverlap("/a/**", "/b/**"); // false

// How do the sets of paths they match relate?
compareRoutes("/api/**", "/api/admin/**"); // "superset"
compareRoutes("/api/admin/**", "/api/**"); // "subset"
compareRoutes("/a/:x", "/a/:y"); // "equal" (param names don't matter)
compareRoutes("/a/*/c", "/a/b/*"); // "partial"
compareRoutes("/a/**", "/b/**"); // "disjoint"

// Which registered routes can match a path that the pattern matches?
const router = createRouter();
addRoute(router, "GET", "/**", { isr: true });
addRoute(router, "GET", "/protected/**", { basicAuth: true });
addRoute(router, "GET", "/protected/feed/**", { isr: 60 });

findOverlappingRoutes(router, "GET", "/protected/feed/**");
// [
//   { data: { isr: true } },       // /**
//   { data: { basicAuth: true } }, // /protected/**
//   { data: { isr: 60 } },         // /protected/feed/**
// ]

These utilities understand the full pattern syntax (groups, modifiers, escapes) using the same rules as the router, so their answers agree with findRoute. Answers are safe, not always exact: a containment or disjointness claim is proven, and anything that can't be decided (such as two different regex constraints) comes back as "partial". See precision and limits.

Route node keys

Different patterns can end on the same node of the route tree: /users/:id and /users/:name both mean "any single segment under /users", and /users/*, /users/** and /users/**:rest are catch-alls under /users. routeNodeKeys(pattern) returns the node(s) a pattern lands on, naming a param segment :_0, :_1, … and a catch-all **:

import { routeNodeKeys } from "rou3";

routeNodeKeys("/users/:id"); // ["/users/:_0"]
routeNodeKeys("/users/:name"); // ["/users/:_0"] (same node as /users/:id)
routeNodeKeys("/users/*"); // ["/users/**"] (same node as /users/**)
routeNodeKeys("/**:path/og.png"); // ["/**/og.png"]
routeNodeKeys("/a/:x?"); // ["/a", "/a/:_0"] (optional syntax lands on two nodes)

Routes on the same node compete: for a given path, findRoute returns at most one of them. If you keep your own per-route metadata (route rules, middleware, auth) in a map keyed by pattern text, /users/* and /users/**:rest look unrelated. Key the map by routeNodeKeys instead, to merge metadata per node or warn when one route shadows another.

routeNodeKeys(a) and routeNodeKeys(b) share a key if and only if a and b share a node.

[!IMPORTANT] Sharing a node does not mean matching the same paths. Keys drop regex constraints and widen **:name and * to **, so /u/:id(\d+) and /u/:slug([a-z]+) share the key /u/:_0 but never match the same path. To compare which paths two patterns match, use compareRoutes.

Regular expressions

routeToRegExp(route) converts a pattern into an anchored RegExp with named groups for the params:

import { routeToRegExp } from "rou3";

const re = routeToRegExp("/users/:id(\\d+)");
// /^\/users\/(?<id>\d+)\/?$/

"/users/123".match(re).groups; // { id: "123" }

The regex matches exactly the paths findRoute matches on a router that only holds that route, including the router's tolerances (one optional trailing slash, empty segments, an optional trailing *, segments after a catch-all matched from the end). That makes it safe to use as a guard or scope check outside the router. Like findRoute without normalize, it compares paths as-is.

The one exception is a regex constraint that can match /, such as (.+): the router applies it to one segment, but in the regex it can span several, so routeToRegExp("/foo/(.+)") also matches /foo/a/b. The regex then matches more paths than the router, never fewer, so a guard built on it still runs.

The output is PCRE-compatible, so its .source also works in grep -P, rg -P, PHP preg_* and Perl. Most routes also compile without look-behind, so they work in RE2-family engines (RE2, Go regexp, Rust regex). A few shapes of params sharing a segment can still backtrack on long failing paths; cap the path length where that matters (most servers already do).

regExpToRoute(regexp) goes the other way. It accepts a RegExp or its source string:

import { regExpToRoute } from "rou3";

regExpToRoute(/^\/users\/(?<id>\d+)\/?$/); // "/users/:id(\\d+)"
regExpToRoute(/^\/path\/(?<param>[^/]+)\/?$/); // "/path/:param"
regExpToRoute("^\\/files\\/(?<_0>[\\s\\S]*)\\.png\\/?$"); // "/files/*.png"

It understands the regexes routeToRegExp emits, and every one of them round-trips exactly: routeToRegExp(regExpToRoute(re)).source === re.source. It also reads the looser regexes older rou3 versions emitted. Anything else throws a rou3: error instead of returning a wrong pattern.

The reference has what the regex doesn't model, backtracking, the regex for each kind of route, engine support and the forms regExpToRoute accepts.

TypeScript

Route data is typed through the router, and InferRouteParams gives you the params of a pattern:

import { createRouter, addRoute, findRoute, type InferRouteParams } from "rou3";

const router = createRouter<{ page: string }>();
addRoute(router, "GET", "/users/:id", { page: "user" });

findRoute(router, "GET", "/users/42")?.data.page; // string

type Params = InferRouteParams<"/users/:id/:tab?">;
// { id: string; tab: string | undefined }

License

Published under the MIT license.

Join libs.tech

...and unlock some superpowers

GitHub

We won't share your data with anyone else.