diff --git a/README.md b/README.md
index b71a1e9a..dab309cd 100644
--- a/README.md
+++ b/README.md
@@ -77,6 +77,7 @@ dashboards and more.
- [``](#switch-)
- [``](#redirect-topath-)
- [``](#router-hookhook-parserfn-basebasepath-hrefsfn-)
+ - [URLPattern route matching](#urlpattern-route-matching)
- [FAQ and Code Recipes](#faq-and-code-recipes)
- [I deploy my app to the subfolder. Can I specify a base path?](#i-deploy-my-app-to-the-subfolder-can-i-specify-a-base-path)
@@ -637,8 +638,8 @@ available options:
application routes will be relative to that path. To navigate out to an absolute path, prefix your path with an `~`. [See the FAQ](#are-relative-routes-and-links-supported).
- **`parser: (path: string, loose?: boolean) => { pattern, keys }`** — a pattern parsing
- function. Produces a RegExp for matching the current location against the user-defined patterns like
- `/app/users/:id`. Has the same interface as the [`parse`](https://github.com/lukeed/regexparam?tab=readme-ov-file#regexparamparseinput-regexp) function from `regexparam`. See [this example](#are-strict-routes-supported) that demonstrates custom parser feature.
+ function. Produces a RegExp or an object with a compatible `exec` method for matching the current location against user-defined patterns like
+ `/app/users/:id`. `keys` can be omitted when matches provide named `groups`. The default parser is [`parse`](https://github.com/lukeed/regexparam?tab=readme-ov-file#regexparamparseinput-regexp) from `regexparam`. See [this example](#are-strict-routes-supported) that demonstrates custom parser feature.
- **`ssrPath: string`** and **`ssrSearch: string`** use these when [rendering your app on the server](#server-side-rendering-support-ssr).
@@ -656,6 +657,26 @@ available options:
};
```
+### URLPattern route matching
+
+To use native [URLPattern](https://developer.mozilla.org/en-US/docs/Web/API/URLPattern) syntax for string routes, opt in with the separate `wouter/url-pattern` module. It requires a browser or runtime with `URLPattern` support:
+
+```jsx
+import { Router, Route } from "wouter";
+import { urlPatternParser } from "wouter/url-pattern";
+
+
+ {/* Matches /users and /users/42 */}
+
+ {(params) => <>User: {params.id ?? "all"}>}
+
+;
+```
+
+For Preact, import from `wouter-preact/url-pattern`. The adapter matches only the pathname and supports nested routes with `nest`. It follows native URLPattern semantics: matching is case-sensitive, and `/users` and `/users/` are distinct. Parameters are the native pathname groups, including numeric names for unnamed groups, and their values remain URL-encoded. Regular expression routes still work. The default parser and its syntax remain unchanged unless you select this adapter.
+
+TypeScript's automatic parameter inference follows the default parser's syntax. For more complex URLPattern patterns, specify parameter types explicitly, such as `useRoute<{ id?: string }>("/users{/:id}?")` or ` path="/users{/:id}?">`. You can also use the exported `DefaultParams` type for arbitrary parameter names.
+
## FAQ and Code Recipes
### I deploy my app to the subfolder. Can I specify a base path?
diff --git a/bunfig.toml b/bunfig.toml
index ede45b06..2941161b 100644
--- a/bunfig.toml
+++ b/bunfig.toml
@@ -7,6 +7,7 @@ coveragePathIgnorePatterns = [
# already measured at their original path (react-deps.js is preact-specific and stays measured)
"packages/wouter-preact/src/index.js",
"packages/wouter-preact/src/paths.js",
+ "packages/wouter-preact/src/url-pattern.js",
"packages/wouter-preact/src/memory-location.js",
"packages/wouter-preact/src/use-browser-location.js",
"packages/wouter-preact/src/use-hash-location.js",
diff --git a/package.json b/package.json
index c35342eb..5ce330ca 100644
--- a/package.json
+++ b/package.json
@@ -53,6 +53,10 @@
"use-sync-external-store"
]
},
+ {
+ "path": "packages/wouter/src/url-pattern.js",
+ "limit": "500 B"
+ },
{
"path": "packages/wouter/src/use-hash-location.js",
"limit": "1000 B",
@@ -93,6 +97,10 @@
"preact",
"preact/hooks"
]
+ },
+ {
+ "path": "packages/wouter-preact/src/url-pattern.js",
+ "limit": "500 B"
}
],
"husky": {
diff --git a/packages/wouter-preact/.gitignore b/packages/wouter-preact/.gitignore
index d671cdd5..55bda900 100644
--- a/packages/wouter-preact/.gitignore
+++ b/packages/wouter-preact/.gitignore
@@ -2,6 +2,7 @@
src/index.js
src/memory-location.js
src/paths.js
+src/url-pattern.js
src/use-browser-location.js
src/use-hash-location.js
src/use-sync-external-store.js
diff --git a/packages/wouter-preact/package.json b/packages/wouter-preact/package.json
index a4af3f1c..07693e03 100644
--- a/packages/wouter-preact/package.json
+++ b/packages/wouter-preact/package.json
@@ -34,6 +34,10 @@
"./memory-location": {
"types": "./types/memory-location.d.ts",
"default": "./src/memory-location.js"
+ },
+ "./url-pattern": {
+ "types": "./types/url-pattern.d.ts",
+ "default": "./src/url-pattern.js"
}
},
"types": "types/index.d.ts",
@@ -50,11 +54,14 @@
],
"memory-location": [
"types/memory-location.d.ts"
+ ],
+ "url-pattern": [
+ "types/url-pattern.d.ts"
]
}
},
"scripts": {
- "prepublishOnly": "cp ../wouter/src/index.js ../wouter/src/memory-location.js ../wouter/src/paths.js ../wouter/src/use-browser-location.js ../wouter/src/use-hash-location.js ../wouter/src/use-sync-external-store.js ../wouter/src/use-sync-external-store.native.js src && cp ../../README.md ."
+ "prepublishOnly": "cp ../wouter/src/index.js ../wouter/src/memory-location.js ../wouter/src/paths.js ../wouter/src/url-pattern.js ../wouter/src/use-browser-location.js ../wouter/src/use-hash-location.js ../wouter/src/use-sync-external-store.js ../wouter/src/use-sync-external-store.native.js src && cp ../../README.md ."
},
"author": "Alexey Taktarov ",
"repository": {
diff --git a/packages/wouter-preact/test/preact.test.tsx b/packages/wouter-preact/test/preact.test.tsx
index f2868150..a9173cca 100644
--- a/packages/wouter-preact/test/preact.test.tsx
+++ b/packages/wouter-preact/test/preact.test.tsx
@@ -30,6 +30,7 @@ const filesToCopy = [
"use-sync-external-store.js",
"use-sync-external-store.native.js",
"index.js",
+ "url-pattern.js",
];
async function loadPreact(): Promise {
@@ -198,6 +199,45 @@ describe("Preact support", () => {
act(() => render(null, container));
container.remove();
});
+
+ test("URLPattern routes inherit nested params and react to navigation", async () => {
+ const { Router, Route, Switch, useParams, useRouter } = await loadPreact();
+ const { urlPatternParser } = await import("wouter-preact/url-pattern");
+ const { memoryLocation } = await import("wouter-preact/memory-location");
+ const { hook, navigate } = memoryLocation({
+ path: "/app/users/42/posts/7",
+ });
+ const container = document.body.appendChild(document.createElement("div"));
+ const Post = () => {
+ const { id, post } = useParams<{ id: string; post: string }>();
+ return <>{`${id}:${post}:${useRouter().base}`}>;
+ };
+ try {
+ act(() => {
+ render(
+
+
+
+
+
+
+
+ Fallback
+
+ ,
+ container
+ );
+ });
+ expect(container.textContent).toBe("42:7:/app/users/42");
+ act(() => navigate("/app/users/42/posts/8"));
+ expect(container.textContent).toBe("42:8:/app/users/42");
+ act(() => navigate("/app/users/alex/posts/8"));
+ expect(container.textContent).toBe("Fallback");
+ } finally {
+ act(() => render(null, container));
+ container.remove();
+ }
+ });
});
describe("useSyncExternalStore shim", () => {
diff --git a/packages/wouter-preact/types/router.d.ts b/packages/wouter-preact/types/router.d.ts
index 56a0872e..1d662920 100644
--- a/packages/wouter-preact/types/router.d.ts
+++ b/packages/wouter-preact/types/router.d.ts
@@ -9,7 +9,7 @@ import {
export type Parser = (
route: Path,
loose?: boolean
-) => { pattern: RegExp; keys: string[] };
+) => { pattern: Pick; keys?: string[] };
// Standard navigation options supported by all built-in location hooks
export type NavigateOptions = {
diff --git a/packages/wouter-preact/types/url-pattern.d.ts b/packages/wouter-preact/types/url-pattern.d.ts
new file mode 100644
index 00000000..b55a05bd
--- /dev/null
+++ b/packages/wouter-preact/types/url-pattern.d.ts
@@ -0,0 +1,3 @@
+import { Parser } from "./router.js";
+
+export const urlPatternParser: Parser;
diff --git a/packages/wouter/package.json b/packages/wouter/package.json
index 1794329a..f53e24ba 100644
--- a/packages/wouter/package.json
+++ b/packages/wouter/package.json
@@ -34,6 +34,10 @@
"./memory-location": {
"types": "./types/memory-location.d.ts",
"default": "./src/memory-location.js"
+ },
+ "./url-pattern": {
+ "types": "./types/url-pattern.d.ts",
+ "default": "./src/url-pattern.js"
}
},
"types": "types/index.d.ts",
@@ -50,6 +54,9 @@
],
"memory-location": [
"types/memory-location.d.ts"
+ ],
+ "url-pattern": [
+ "types/url-pattern.d.ts"
]
}
},
diff --git a/packages/wouter/src/url-pattern.d.ts b/packages/wouter/src/url-pattern.d.ts
new file mode 100644
index 00000000..887c3160
--- /dev/null
+++ b/packages/wouter/src/url-pattern.d.ts
@@ -0,0 +1 @@
+export * from "../types/url-pattern.js";
diff --git a/packages/wouter/src/url-pattern.js b/packages/wouter/src/url-pattern.js
new file mode 100644
index 00000000..21f6b4b7
--- /dev/null
+++ b/packages/wouter/src/url-pattern.js
@@ -0,0 +1,36 @@
+/* global URLPattern */
+
+// Opt-in adapter for the native URLPattern API.
+export const urlPatternParser = (route, loose) => {
+ const pattern = new URLPattern({ pathname: route });
+
+ return {
+ pattern: {
+ exec(path) {
+ let end = path.length;
+ do {
+ const prefix = path.slice(0, end);
+ const result = pattern.exec({ pathname: prefix });
+ if (result) {
+ const groups = result.pathname.groups;
+ return Object.assign(
+ [
+ // Keep the original prefix; URLPattern canonicalizes its input.
+ loose && path[end] !== "/" ? prefix.replace(/\/$/, "") : prefix,
+ ],
+ { index: 0, input: path, groups }
+ );
+ }
+
+ if (!loose || !end) break;
+ // Try segment boundaries, including trailing slashes, longest first.
+ end =
+ path[end - 1] === "/"
+ ? end - 1
+ : path.lastIndexOf("/", end - 1) + 1;
+ } while (end > 0 || path[0] === "/");
+ return null;
+ },
+ },
+ };
+};
diff --git a/packages/wouter/test/url-pattern.test-d.tsx b/packages/wouter/test/url-pattern.test-d.tsx
new file mode 100644
index 00000000..fccf6594
--- /dev/null
+++ b/packages/wouter/test/url-pattern.test-d.tsx
@@ -0,0 +1,36 @@
+import { expectTypeOf, test } from "bun:test";
+import { Route, Router, useRoute, type Parser } from "wouter";
+import type { Parser as PreactParser } from "wouter-preact";
+import type { urlPatternParser as ReactURLPatternParser } from "wouter/url-pattern";
+import type { urlPatternParser as PreactURLPatternParser } from "wouter-preact/url-pattern";
+
+test("both package subpaths export a compatible URLPattern parser", () => {
+ expectTypeOf().toEqualTypeOf();
+ expectTypeOf().toEqualTypeOf();
+});
+
+test("custom parsers need only exec and may omit keys for named groups", () => {
+ const parser: Parser = (route, loose) => ({
+ pattern: {
+ exec: (input) => new RegExp(route + (loose ? "" : "$")).exec(input),
+ },
+ });
+ const preactParser: PreactParser = parser;
+ expectTypeOf(preactParser).toEqualTypeOf();
+ Custom parser;
+});
+
+test("native URLPattern syntax accepts explicit parameter types", () => {
+ path="/users{/:id}?">
+ {({ id }) => {
+ expectTypeOf(id).toEqualTypeOf();
+ return id ?? "All users";
+ }}
+ ;
+ const User = () => {
+ const [matches, params] = useRoute<{ id: string }>("/users/:id(\\d+)");
+ if (matches) expectTypeOf(params.id).toEqualTypeOf();
+ return null;
+ };
+ expectTypeOf(User).toBeFunction();
+});
diff --git a/packages/wouter/test/url-pattern.test.tsx b/packages/wouter/test/url-pattern.test.tsx
new file mode 100644
index 00000000..26c83e99
--- /dev/null
+++ b/packages/wouter/test/url-pattern.test.tsx
@@ -0,0 +1,204 @@
+import { expect, test } from "bun:test";
+import { act, fireEvent, render, renderHook } from "@testing-library/react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { parse } from "regexparam";
+
+import {
+ Link,
+ Route,
+ Router,
+ Switch,
+ matchRoute,
+ useLocation,
+ useParams,
+ useRoute,
+ useRouter,
+} from "../src/index.js";
+import { memoryLocation } from "../src/memory-location.js";
+import { urlPatternParser } from "wouter/url-pattern";
+import { withoutLocation } from "./setup.js";
+
+test("matches required, optional and constrained URLPattern parameters", () => {
+ expect(
+ matchRoute(urlPatternParser, "/users/:id", "/users/42")
+ ).toStrictEqual([true, { id: "42" }]);
+ expect(
+ matchRoute(urlPatternParser, "/users/:id", "/users")
+ ).toStrictEqual([false, null]);
+ expect(
+ matchRoute(urlPatternParser, "/users{/:id}?", "/users")
+ ).toStrictEqual([true, { id: undefined }]);
+ expect(
+ matchRoute(urlPatternParser, "/users{/:id}?", "/users/42")
+ ).toStrictEqual([true, { id: "42" }]);
+ expect(
+ matchRoute(urlPatternParser, "/users/:id(\\d+)", "/users/42")
+ ).toStrictEqual([true, { id: "42" }]);
+ expect(
+ matchRoute(urlPatternParser, "/users/:id(\\d+)", "/users/alex")
+ ).toStrictEqual([false, null]);
+});
+
+test("retains native named, wildcard and repeated groups", () => {
+ expect(
+ matchRoute(urlPatternParser, "/:name/*", "/first/second/third")
+ ).toStrictEqual([true, { 0: "second/third", name: "first" }]);
+ expect(
+ matchRoute(urlPatternParser, "/files/:parts+", "/files/a/b")
+ ).toStrictEqual([true, { parts: "a/b" }]);
+ expect(
+ matchRoute(urlPatternParser, "/files/*", "/files/")
+ ).toStrictEqual([true, { 0: "" }]);
+});
+
+test("keeps native case and trailing slash semantics", () => {
+ expect(
+ matchRoute(urlPatternParser, "/users", "/Users")
+ ).toStrictEqual([false, null]);
+ expect(
+ matchRoute(urlPatternParser, "/users", "/users/")
+ ).toStrictEqual([false, null]);
+ expect(
+ matchRoute(urlPatternParser, "/users/", "/users")
+ ).toStrictEqual([false, null]);
+ expect(
+ matchRoute(urlPatternParser, "/users/", "/users/")
+ ).toStrictEqual([true, {}]);
+});
+
+test.each([
+ ["/users/:id", "/users/42/settings", { id: "42" }, "/users/42"],
+ ["/users{/:id}?", "/users/42/settings", { id: "42" }, "/users/42"],
+ ["/users{/:id}?", "/users", { id: undefined }, "/users"],
+ ["/files/:parts+", "/files/a/b", { parts: "a/b" }, "/files/a/b"],
+ ["/users/", "/users/settings", {}, "/users"],
+ ["/users/", "/users//settings", {}, "/users/"],
+ ["/users/", "/users/", {}, "/users"],
+ ["/users//", "/users//settings", {}, "/users/"],
+ ["/", "/users/42", {}, ""],
+ ["/", "/", {}, ""],
+ ["/:id?", "/", { id: undefined }, ""],
+] as const)(
+ "matches the longest slash-delimited prefix for nested %s at %s",
+ (route, path, params, base) => {
+ expect(
+ matchRoute(urlPatternParser, route, path, true)
+ ).toStrictEqual([true, params, base]);
+ }
+);
+
+test("loose matching respects boundaries without adding synthetic parameters", () => {
+ expect(
+ matchRoute(urlPatternParser, "/users", "/users-extra/settings", true)
+ ).toStrictEqual([false, null]);
+ expect(
+ matchRoute(
+ urlPatternParser,
+ "/users/:__wouter_rest",
+ "/users/42/settings",
+ true
+ )
+ ).toStrictEqual([true, { __wouter_rest: "42" }, "/users/42"]);
+ expect(
+ matchRoute(urlPatternParser, "/users/:name", "/users/José/settings", true)
+ ).toStrictEqual([true, { name: "Jos%C3%A9" }, "/users/José"]);
+});
+
+test("leaves the default parser and RegExp routes compatible", () => {
+ expect(matchRoute(parse, "/users/:id", "/Users/42/")).toStrictEqual([
+ true,
+ { 0: "42", id: "42" },
+ ]);
+ expect(
+ matchRoute(
+ urlPatternParser,
+ /^\/users\/(?\d+)/,
+ "/users/42/settings",
+ true
+ )
+ ).toStrictEqual([true, { 0: "42", id: "42" }, "/users/42"]);
+ const { result } = renderHook(() => useRouter().parser);
+ expect(result.current).toBe(parse);
+});
+
+test("useRoute supports router bases, absolute escapes and navigation", () => {
+ const { hook, navigate } = memoryLocation({ path: "/app/users/42" });
+ const { result } = renderHook(
+ () => [useRoute("/users/:id(\\d+)"), useRoute("~/app/users/:id")],
+ {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ }
+ );
+ expect(result.current).toStrictEqual([
+ [true, { id: "42" }],
+ [true, { id: "42" }],
+ ]);
+ act(() => navigate("/app/users/alex"));
+ expect(result.current).toStrictEqual([
+ [false, null],
+ [true, { id: "alex" }],
+ ]);
+});
+
+test("Switch, inherited params and links work with nested URLPattern routes", () => {
+ const { hook, navigate } = memoryLocation({ path: "/app/users/42/posts/7" });
+ const Post = () => {
+ const params = useParams<{ id: string; post: string }>();
+ const [path] = useLocation();
+ return (
+ <>
+ {`${params.id}:${params.post}:${useRouter().base}:${path}`}
+ Next
+ Outside
+ >
+ );
+ };
+ const { container } = render(
+
+
+ About
+
+
+
+
+
+ Fallback
+
+
+ );
+ expect(container.querySelector("span")?.textContent).toBe(
+ "42:7:/app/users/42:/posts/7"
+ );
+ const [next, outside] = Array.from(container.querySelectorAll("a"));
+ expect(next.getAttribute("href")).toBe("/app/users/42/posts/8");
+ expect(outside.getAttribute("href")).toBe("/outside");
+ fireEvent.click(next);
+ expect(container.querySelector("span")?.textContent).toBe(
+ "42:8:/app/users/42:/posts/8"
+ );
+ fireEvent.click(outside);
+ expect(container.textContent).toBe("Fallback");
+ act(() => navigate("/app/about"));
+ expect(container.textContent).toBe("About");
+});
+
+test("root and trailing slash nesting leave the child pathname intact in SSR", () => {
+ const rendered = withoutLocation(() =>
+ renderToStaticMarkup(
+
+
+
+ path={"/:id(\\d+)"}>
+ {({ id }) => `User ${id}`}
+
+
+
+
+ )
+ );
+ expect(rendered).toBe("User 42");
+});
diff --git a/packages/wouter/types/router.d.ts b/packages/wouter/types/router.d.ts
index 56a0872e..1d662920 100644
--- a/packages/wouter/types/router.d.ts
+++ b/packages/wouter/types/router.d.ts
@@ -9,7 +9,7 @@ import {
export type Parser = (
route: Path,
loose?: boolean
-) => { pattern: RegExp; keys: string[] };
+) => { pattern: Pick; keys?: string[] };
// Standard navigation options supported by all built-in location hooks
export type NavigateOptions = {
diff --git a/packages/wouter/types/url-pattern.d.ts b/packages/wouter/types/url-pattern.d.ts
new file mode 100644
index 00000000..b55a05bd
--- /dev/null
+++ b/packages/wouter/types/url-pattern.d.ts
@@ -0,0 +1,3 @@
+import { Parser } from "./router.js";
+
+export const urlPatternParser: Parser;