+ The boilerplate also wires a typed not-found route for unmatched URLs.
+
+
+ );
+ },
+});
+
+export function createServerAppRouter(
+ initialData?: RouterDehydratedState,
+) {
+ return createRouter(appRoutes, {
+ hydratedState: initialData,
+ middleware: [withAuth("/login")],
+ notFound: notFoundRoute,
+ });
+}
+
+export function createHydratedAppRouter() {
+ return createHydratedRouter(appRoutes, {
+ middleware: [withAuth("/login")],
+ notFound: notFoundRoute,
+ });
+}
+
+export function normalizeRequestHref(input: string | URL): string {
+ return toHref(input);
+}
diff --git a/apps/react/boilerplate-vite/src/ssr/Shell.tsx b/apps/react/boilerplate-vite/src/ssr/Shell.tsx
index e39d7ca98..aac6bc559 100644
--- a/apps/react/boilerplate-vite/src/ssr/Shell.tsx
+++ b/apps/react/boilerplate-vite/src/ssr/Shell.tsx
@@ -1,37 +1,34 @@
import type { ServerEntrypointProps } from "@canonical/react-ssr/renderer";
-import Application from "../Application.js";
+import type { ReactElement, ReactNode } from "react";
+
+interface ShellProps extends ServerEntrypointProps> {
+ readonly children: ReactNode;
+ readonly navigation: ReactNode;
+}
export type InitialData = Record;
-/**
- * This function returns the component that renders the full page both in the Server and in the
- * Client (as it needs to match exactly for hydration to work).
- * If you need to pass the initial data to the Renderer constructor.
- *
- * @param props props can be all automatically extracted by the renderer from the HTML index page
- * or can be provided programmatically to the renderer constructor.
- * @returns root component containing all the HTML of the page to be rendered.
- */
-function Shell(props: ServerEntrypointProps) {
+export default function Shell(props: ShellProps): ReactElement {
return (
- Canonical React Vite Boilerplate
+ Canonical router boilerplate
+
{props.otherHeadElements}
{props.scriptElements}
{props.linkElements}
- {
- // Add the following to pass initial data to the Application:
- //
- }
-
+
+ {props.navigation}
+ {props.children}
+
);
}
-
-export default Shell;
diff --git a/apps/react/boilerplate-vite/src/ssr/entry-client.tsx b/apps/react/boilerplate-vite/src/ssr/entry-client.tsx
index a077615f7..61b846b68 100644
--- a/apps/react/boilerplate-vite/src/ssr/entry-client.tsx
+++ b/apps/react/boilerplate-vite/src/ssr/entry-client.tsx
@@ -1,8 +1,21 @@
+import { Outlet, RouterProvider } from "@canonical/router-react";
import { hydrateRoot } from "react-dom/client";
+import Navigation from "../Navigation.js";
+import { createHydratedAppRouter } from "../routes.js";
+import "../Application.css";
import "../index.css";
import Shell from "./Shell.js";
-// entry-server page must match exactly the hydrated page in entry-client
-hydrateRoot(document, );
+const router = createHydratedAppRouter();
-console.log("hydrated");
+hydrateRoot(
+ document,
+
+ }
+ >
+ Loading route…
} />
+
+ ,
+);
diff --git a/apps/react/boilerplate-vite/src/ssr/entry-server.test.tsx b/apps/react/boilerplate-vite/src/ssr/entry-server.test.tsx
new file mode 100644
index 000000000..4319f0228
--- /dev/null
+++ b/apps/react/boilerplate-vite/src/ssr/entry-server.test.tsx
@@ -0,0 +1,29 @@
+import { renderToString } from "react-dom/server";
+import { describe, expect, it } from "vitest";
+import { createServerAppRouter } from "../routes.js";
+import EntryServer from "./entry-server.js";
+
+describe("EntryServer", () => {
+ it("renders the shell and the server-routed page", async () => {
+ const router = createServerAppRouter();
+
+ await router.load("/guides/router-core");
+ const initialData = router.dehydrate();
+
+ if (!initialData) {
+ throw new Error("Expected dehydrated router state for SSR test.");
+ }
+
+ const html = renderToString(
+ }
+ lang="en"
+ />,
+ );
+
+ expect(html).toContain("Canonical router boilerplate");
+ expect(html).toContain("Guide:");
+ expect(html).toContain("router-core");
+ expect(html).toContain("Hover a navigation link to prefetch route data");
+ });
+});
diff --git a/apps/react/boilerplate-vite/src/ssr/entry-server.tsx b/apps/react/boilerplate-vite/src/ssr/entry-server.tsx
index 4bd583ced..e7b75a6b1 100644
--- a/apps/react/boilerplate-vite/src/ssr/entry-server.tsx
+++ b/apps/react/boilerplate-vite/src/ssr/entry-server.tsx
@@ -1,6 +1,25 @@
import type { ServerEntrypoint } from "@canonical/react-ssr/renderer";
+import type { RouterDehydratedState, RouteMap } from "@canonical/router-core";
+import { Outlet, RouterProvider } from "@canonical/router-react";
+import Navigation from "../Navigation.js";
+import { createServerAppRouter } from "../routes.js";
import Shell, { type InitialData } from "./Shell.js";
-const EntryServer: ServerEntrypoint = Shell;
+const EntryServer: ServerEntrypoint = (props) => {
+ const initialData = props.initialData as
+ | RouterDehydratedState
+ | undefined;
+ const router = createServerAppRouter(initialData);
+
+ return (
+
+ }>
+ Loading route…}
+ />
+
+
+ );
+};
export default EntryServer;
diff --git a/apps/react/boilerplate-vite/src/ssr/renderer.tsx b/apps/react/boilerplate-vite/src/ssr/renderer.tsx
index 216c023db..ecd9c9816 100644
--- a/apps/react/boilerplate-vite/src/ssr/renderer.tsx
+++ b/apps/react/boilerplate-vite/src/ssr/renderer.tsx
@@ -1,13 +1,31 @@
import fs from "node:fs/promises";
import path from "node:path";
import { JSXRenderer } from "@canonical/react-ssr/renderer";
+import { createServerAppRouter, normalizeRequestHref } from "../routes.js";
import EntryServer from "./entry-server.js";
+import type { InitialData } from "./Shell.js";
export const htmlString = await fs.readFile(
path.join(process.cwd(), "dist", "client", "index.html"),
"utf-8",
);
-export default function createRenderer() {
- return new JSXRenderer(EntryServer, {}, { htmlString });
+export interface RenderPreparation {
+ readonly initialData: InitialData;
+ readonly renderer: JSXRenderer;
+}
+
+export default async function prepareRender(
+ requestUrl: string,
+): Promise {
+ const router = createServerAppRouter();
+ const loadResult = await router.load(normalizeRequestHref(requestUrl));
+ const initialData = loadResult.dehydrate() as unknown as InitialData;
+
+ return {
+ initialData,
+ renderer: new JSXRenderer(EntryServer, initialData, {
+ htmlString,
+ }),
+ };
}
diff --git a/apps/react/boilerplate-vite/src/ssr/server.ts b/apps/react/boilerplate-vite/src/ssr/server.ts
index e7e369552..b9ac115a2 100644
--- a/apps/react/boilerplate-vite/src/ssr/server.ts
+++ b/apps/react/boilerplate-vite/src/ssr/server.ts
@@ -1,15 +1,37 @@
import * as process from "node:process";
-import { serveStream } from "@canonical/react-ssr/server";
import express from "express";
-import createRenderer from "./renderer.js";
+import { getAuthRedirectHref, normalizeRequestHref } from "../routes.js";
+import prepareRender from "./renderer.js";
const PORT = process.env.PORT || 5173;
const app = express();
-app.use(/^\/(assets|public)/, express.static("dist/client/assets"));
+app.use(/^\/assets/, express.static("dist/client/assets"));
-app.use(serveStream(createRenderer));
+app.use(async (req, res, next) => {
+ try {
+ const requestHref = normalizeRequestHref(req.originalUrl || req.url || "/");
+ const redirectHref = getAuthRedirectHref(requestHref);
+
+ if (redirectHref) {
+ res.redirect(302, redirectHref);
+ return;
+ }
+
+ const { renderer } = await prepareRender(requestHref);
+
+ const result = renderer.renderToPipeableStream();
+
+ await renderer.statusReady;
+ res.writeHead(renderer.statusCode, {
+ "Content-Type": "text/html; charset=utf-8",
+ });
+ result.pipe(res);
+ } catch (error) {
+ next(error);
+ }
+});
app.listen(PORT, () => {
console.log(`Server started on http://localhost:${PORT}/`);
diff --git a/apps/react/boilerplate-vite/vitest.config.ts b/apps/react/boilerplate-vite/vitest.config.ts
new file mode 100644
index 000000000..7ace23cd7
--- /dev/null
+++ b/apps/react/boilerplate-vite/vitest.config.ts
@@ -0,0 +1,25 @@
+import react from "@vitejs/plugin-react";
+import tsconfigPaths from "vite-tsconfig-paths";
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ plugins: [tsconfigPaths(), react()],
+ test: {
+ coverage: {
+ exclude: [],
+ include: [
+ "src/Navigation.tsx",
+ "src/domains/**/*.tsx",
+ "src/routes.tsx",
+ "src/ssr/Shell.tsx",
+ "src/ssr/entry-server.tsx",
+ ],
+ provider: "v8",
+ reporter: ["text"],
+ },
+ environment: "jsdom",
+ globals: true,
+ include: ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/*.tests.tsx"],
+ setupFiles: ["./vitest.setup.ts"],
+ },
+});
diff --git a/apps/react/boilerplate-vite/vitest.setup.ts b/apps/react/boilerplate-vite/vitest.setup.ts
new file mode 100644
index 000000000..f149f27ae
--- /dev/null
+++ b/apps/react/boilerplate-vite/vitest.setup.ts
@@ -0,0 +1 @@
+import "@testing-library/jest-dom/vitest";
diff --git a/bun.lock b/bun.lock
index b121be428..bc6d0aefc 100644
--- a/bun.lock
+++ b/bun.lock
@@ -14,18 +14,20 @@
"name": "@canonical/react-boilerplate-vite",
"version": "0.22.0",
"dependencies": {
- "@canonical/react-ds-global": "^0.20.0",
- "@canonical/react-ssr": "^0.20.0",
- "@canonical/storybook-config": "^0.20.0",
- "@canonical/styles": "^0.20.0",
+ "@canonical/react-ds-global": "^0.22.0",
+ "@canonical/react-ssr": "^0.22.0",
+ "@canonical/router-core": "^0.22.0",
+ "@canonical/router-react": "^0.22.0",
+ "@canonical/storybook-config": "^0.22.0",
+ "@canonical/styles": "^0.22.0",
"express": "^5.2.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
},
"devDependencies": {
"@biomejs/biome": "2.4.9",
- "@canonical/biome-config": "^0.20.0",
- "@canonical/typescript-config-react": "^0.20.0",
+ "@canonical/biome-config": "^0.22.0",
+ "@canonical/typescript-config-react": "^0.22.0",
"@chromatic-com/storybook": "^5.0.1",
"@storybook/react-vite": "^10.3.1",
"@testing-library/jest-dom": "^6.9.1",
@@ -35,6 +37,7 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.0",
+ "@vitest/coverage-v8": "4.1.2",
"globals": "^17.4.0",
"storybook": "^10.3.1",
"typescript": "^5.9.3",
@@ -134,10 +137,10 @@
"name": "@canonical/storybook-config",
"version": "0.22.0",
"dependencies": {
- "@canonical/ds-assets": "^0.21.0",
- "@canonical/storybook-addon-shell-theme": "^0.21.0",
- "@canonical/storybook-addon-utils": "^0.21.0",
- "@canonical/styles-debug": "^0.21.0",
+ "@canonical/ds-assets": "^0.22.0",
+ "@canonical/storybook-addon-shell-theme": "^0.22.0",
+ "@canonical/storybook-addon-utils": "^0.22.0",
+ "@canonical/styles-debug": "^0.22.0",
"@chromatic-com/storybook": "^5.0.1",
"@storybook/addon-a11y": "^10.3.1",
"@storybook/addon-docs": "^10.3.1",
@@ -149,8 +152,8 @@
},
"devDependencies": {
"@biomejs/biome": "2.4.9",
- "@canonical/biome-config": "^0.21.0",
- "@canonical/typescript-config-react": "^0.21.0",
+ "@canonical/biome-config": "^0.22.0",
+ "@canonical/typescript-config-react": "^0.22.0",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@@ -316,9 +319,9 @@
"version": "0.22.0",
"devDependencies": {
"@biomejs/biome": "2.4.9",
- "@canonical/biome-config": "^0.21.0",
- "@canonical/typescript-config": "^0.21.0",
- "@canonical/webarchitect": "^0.21.0",
+ "@canonical/biome-config": "^0.22.0",
+ "@canonical/typescript-config": "^0.22.0",
+ "@canonical/webarchitect": "^0.22.0",
"@types/jsdom": "^28.0.0",
"@types/node": "^24.12.0",
"jsdom": "^28.1.0",
@@ -591,22 +594,22 @@
"name": "@canonical/react-ds-global",
"version": "0.22.0",
"dependencies": {
- "@canonical/ds-assets": "^0.20.0",
- "@canonical/storybook-config": "^0.20.0",
- "@canonical/styles": "^0.20.0",
- "@canonical/styles-old": "^0.20.0",
- "@canonical/utils": "^0.20.0",
+ "@canonical/ds-assets": "^0.22.0",
+ "@canonical/storybook-config": "^0.22.0",
+ "@canonical/styles": "^0.22.0",
+ "@canonical/styles-old": "^0.22.0",
+ "@canonical/utils": "^0.22.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
},
"devDependencies": {
"@biomejs/biome": "2.4.9",
- "@canonical/biome-config": "^0.20.0",
+ "@canonical/biome-config": "^0.22.0",
"@canonical/design-tokens": "^0.6.0",
- "@canonical/ds-types": "^0.20.0",
- "@canonical/storybook-helpers": "^0.20.0",
- "@canonical/typescript-config-react": "^0.20.0",
- "@canonical/webarchitect": "^0.20.0",
+ "@canonical/ds-types": "^0.22.0",
+ "@canonical/storybook-helpers": "^0.22.0",
+ "@canonical/typescript-config-react": "^0.22.0",
+ "@canonical/webarchitect": "^0.22.0",
"@chromatic-com/storybook": "^5.0.1",
"@storybook/addon-docs": "^10.3.1",
"@storybook/react-vite": "^10.3.1",
@@ -832,9 +835,9 @@
"version": "0.22.0",
"devDependencies": {
"@biomejs/biome": "2.4.9",
- "@canonical/biome-config": "^0.21.0",
- "@canonical/typescript-config": "^0.21.0",
- "@canonical/webarchitect": "^0.21.0",
+ "@canonical/biome-config": "^0.22.0",
+ "@canonical/typescript-config": "^0.22.0",
+ "@canonical/webarchitect": "^0.22.0",
"copyfiles": "^2.4.1",
"storybook": "^10.3.1",
"typescript": "^5.9.3",
@@ -902,14 +905,14 @@
"name": "@canonical/storybook-addon-utils",
"version": "0.22.0",
"dependencies": {
- "@canonical/styles-debug": "^0.21.0",
+ "@canonical/styles-debug": "^0.22.0",
"@storybook/icons": "^2.0.1",
},
"devDependencies": {
"@biomejs/biome": "2.4.9",
- "@canonical/biome-config": "^0.21.0",
- "@canonical/styles": "^0.21.0",
- "@canonical/typescript-config-react": "^0.21.0",
+ "@canonical/biome-config": "^0.22.0",
+ "@canonical/styles": "^0.22.0",
+ "@canonical/typescript-config-react": "^0.22.0",
"@storybook/addon-docs": "^10.3.1",
"@storybook/react-vite": "^10.3.1",
"@types/react": "^19.2.14",
@@ -931,15 +934,15 @@
"name": "@canonical/storybook-helpers",
"version": "0.22.0",
"dependencies": {
- "@canonical/ds-types": "^0.21.0",
+ "@canonical/ds-types": "^0.22.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
},
"devDependencies": {
"@biomejs/biome": "2.4.9",
- "@canonical/biome-config": "^0.21.0",
- "@canonical/typescript-config-react": "^0.21.0",
- "@canonical/webarchitect": "^0.21.0",
+ "@canonical/biome-config": "^0.22.0",
+ "@canonical/typescript-config-react": "^0.22.0",
+ "@canonical/webarchitect": "^0.22.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.2.14",
@@ -961,24 +964,24 @@
},
"devDependencies": {
"@biomejs/biome": "2.4.9",
- "@canonical/biome-config": "^0.20.0",
+ "@canonical/biome-config": "^0.22.0",
},
},
"packages/styles-old/main/canonical": {
"name": "@canonical/styles-old",
"version": "0.22.0",
"dependencies": {
- "@canonical/styles-elements": "^0.20.0",
- "@canonical/styles-modes-canonical": "^0.20.0",
- "@canonical/styles-modes-density": "^0.20.0",
- "@canonical/styles-modes-intents": "^0.20.0",
- "@canonical/styles-modes-motion": "^0.20.0",
- "@canonical/styles-primitives-canonical": "^0.20.0",
+ "@canonical/styles-elements": "^0.22.0",
+ "@canonical/styles-modes-canonical": "^0.22.0",
+ "@canonical/styles-modes-density": "^0.22.0",
+ "@canonical/styles-modes-intents": "^0.22.0",
+ "@canonical/styles-modes-motion": "^0.22.0",
+ "@canonical/styles-primitives-canonical": "^0.22.0",
"normalize.css": "^8.0.1",
},
"devDependencies": {
"@biomejs/biome": "2.4.9",
- "@canonical/biome-config": "^0.20.0",
+ "@canonical/biome-config": "^0.22.0",
},
},
"packages/styles-old/modes/canonical": {
@@ -986,7 +989,7 @@
"version": "0.22.0",
"devDependencies": {
"@biomejs/biome": "2.4.9",
- "@canonical/biome-config": "^0.20.0",
+ "@canonical/biome-config": "^0.22.0",
},
},
"packages/styles-old/modes/density": {
@@ -994,7 +997,7 @@
"version": "0.22.0",
"devDependencies": {
"@biomejs/biome": "2.4.9",
- "@canonical/biome-config": "^0.20.0",
+ "@canonical/biome-config": "^0.22.0",
},
},
"packages/styles-old/modes/intents": {
@@ -1005,7 +1008,7 @@
},
"devDependencies": {
"@biomejs/biome": "2.4.9",
- "@canonical/biome-config": "^0.20.0",
+ "@canonical/biome-config": "^0.22.0",
},
},
"packages/styles-old/modes/motion": {
@@ -1016,7 +1019,7 @@
},
"devDependencies": {
"@biomejs/biome": "2.4.9",
- "@canonical/biome-config": "^0.20.0",
+ "@canonical/biome-config": "^0.22.0",
},
},
"packages/styles-old/primitives/canonical": {
@@ -1029,7 +1032,7 @@
},
"devDependencies": {
"@biomejs/biome": "2.4.9",
- "@canonical/biome-config": "^0.20.0",
+ "@canonical/biome-config": "^0.22.0",
"@canonical/tokens": "^0.9.0",
},
},
@@ -1038,7 +1041,7 @@
"version": "0.22.0",
"devDependencies": {
"@biomejs/biome": "2.4.9",
- "@canonical/biome-config": "^0.21.0",
+ "@canonical/biome-config": "^0.22.0",
},
"peerDependencies": {
"@canonical/design-tokens": ">=0.4.0",
@@ -1052,12 +1055,12 @@
"version": "0.22.0",
"dependencies": {
"@canonical/design-tokens": "^0.6.0",
- "@canonical/styles-typography": "^0.21.0",
+ "@canonical/styles-typography": "^0.22.0",
"normalize.css": "^8.0.1",
},
"devDependencies": {
"@biomejs/biome": "2.4.9",
- "@canonical/biome-config": "^0.21.0",
+ "@canonical/biome-config": "^0.22.0",
},
"peerDependencies": {
"@canonical/ds-assets": ">=0.18.0",
@@ -1078,8 +1081,8 @@
},
"devDependencies": {
"@biomejs/biome": "2.4.9",
- "@canonical/biome-config": "^0.21.0",
- "@canonical/typescript-config": "^0.21.0",
+ "@canonical/biome-config": "^0.22.0",
+ "@canonical/typescript-config": "^0.22.0",
"@types/bun": "^1.3.10",
"@types/node": "^24.12.0",
"typescript": "^5.9.3",
@@ -3670,6 +3673,12 @@
"@bundled-es-modules/glob/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
+ "@canonical/react-boilerplate-vite/@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.2", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.2", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.2", "vitest": "4.1.2" }, "optionalPeers": ["@vitest/browser"] }, "sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg=="],
+
+ "@canonical/react-ssr/@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.2", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.2", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.2", "vitest": "4.1.2" }, "optionalPeers": ["@vitest/browser"] }, "sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg=="],
+
+ "@canonical/router-react/@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.2", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.2", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.2", "vitest": "4.1.2" }, "optionalPeers": ["@vitest/browser"] }, "sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg=="],
+
"@canonical/styles-primitives-canonical/@canonical/tokens": ["@canonical/tokens@0.9.0", "", { "dependencies": { "style-dictionary": "^4.3.3" } }, "sha512-S4YC2G80NxbmFU/JgYBJn4zXaQdVkJeIBFcXQurELQXlzHLVOizgacmfVRnw9UKfAGoOK6JezwLwhLyVWI6ozA=="],
"@digitalbazaar/http-client/undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="],
@@ -4074,6 +4083,12 @@
"@bundled-es-modules/glob/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
+ "@canonical/react-boilerplate-vite/@vitest/coverage-v8/@vitest/utils": ["@vitest/utils@4.1.2", "", { "dependencies": { "@vitest/pretty-format": "4.1.2", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ=="],
+
+ "@canonical/react-ssr/@vitest/coverage-v8/@vitest/utils": ["@vitest/utils@4.1.2", "", { "dependencies": { "@vitest/pretty-format": "4.1.2", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ=="],
+
+ "@canonical/router-react/@vitest/coverage-v8/@vitest/utils": ["@vitest/utils@4.1.2", "", { "dependencies": { "@vitest/pretty-format": "4.1.2", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ=="],
+
"@inquirer/core/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"@inquirer/core/wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
diff --git a/docs/how-to-guides/MIGRATE_FROM_TANSTACK_ROUTER.md b/docs/how-to-guides/MIGRATE_FROM_TANSTACK_ROUTER.md
new file mode 100644
index 000000000..7b4eb41c3
--- /dev/null
+++ b/docs/how-to-guides/MIGRATE_FROM_TANSTACK_ROUTER.md
@@ -0,0 +1,224 @@
+# Migrate from TanStack Router to `@canonical/router-react`
+
+This guide maps the most common TanStack Router concepts to `@canonical/router-core` and `@canonical/router-react`.
+
+## When this migration is a good fit
+
+Choose the Canonical router stack when you want:
+
+- flat route definitions instead of nested file or tree APIs
+- wrapper composition that is explicit and reusable across route groups
+- lightweight typed navigation helpers
+- SSR dehydration without a framework-specific route compiler
+- a small surface area you can embed into existing apps and generators
+
+## Concept mapping
+
+| TanStack Router | Canonical router |
+|---|---|
+| `createRootRoute`, `createRoute`, route tree | `route()` plus a flat `RouteMap` |
+| layout routes | `wrapper()` + `group()` |
+| `createRouter()` | `createRouter()` |
+| `Link` | `Link` |
+| `Outlet` | `Outlet` |
+| loaders | route `fetch()` |
+| route component | route `content` |
+| route error component | route `error` |
+| redirects | static redirect routes or `redirect()` |
+| before-load auth checks | middleware such as `withAuth()` |
+| dehydrated loader state | `dehydrate()` / `hydratedState` / `createHydratedRouter()` |
+
+## 1. Replace the route tree with flat routes
+
+### TanStack Router
+
+```tsx
+const rootRoute = createRootRoute({
+ component: RootLayout,
+});
+
+const accountRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: "/account/$team",
+ loader: ({ params }) => fetchTeam(params.team),
+ component: AccountPage,
+});
+
+const routeTree = rootRoute.addChildren([accountRoute]);
+```
+
+### Canonical router
+
+```tsx
+import { createRouter, route } from "@canonical/router-core";
+
+const routes = {
+ account: route({
+ url: "/account/:team",
+ fetch: async ({ team }) => fetchTeam(team),
+ content: ({ data }) => ,
+ }),
+} as const;
+
+const router = createRouter(routes);
+```
+
+## 2. Replace layout routes with wrappers
+
+### TanStack Router
+
+A parent route often carries layout and shared loader concerns.
+
+### Canonical router
+
+Move that concern into a wrapper and apply it to a flat route list.
+
+```tsx
+import { group, route, wrapper } from "@canonical/router-core";
+
+const appShell = wrapper({
+ id: "app:shell",
+ component: ({ children }) => {children},
+ fetch: async () => fetchViewer(),
+});
+
+const [dashboardRoute, reportsRoute] = group(appShell, [
+ route({ url: "/dashboard", content: () => }),
+ route({ url: "/reports", content: () => }),
+] as const);
+```
+
+## 3. Move loaders to `fetch()`
+
+TanStack Router loaders become route or wrapper `fetch()` functions.
+
+```tsx
+const userRoute = route({
+ url: "/users/:id",
+ fetch: async ({ id }, search, context) => fetchUser(id, search, context),
+ content: ({ data }) => ,
+});
+```
+
+## 4. Move route components to `content`
+
+`component` maps directly to `content`.
+
+```tsx
+const settingsRoute = route({
+ url: "/settings",
+ content: () => ,
+});
+```
+
+## 5. Replace `beforeLoad` with middleware or wrapper fetches
+
+If your TanStack route used `beforeLoad` for auth or locale setup, prefer one of these:
+
+- **middleware** when the concern should be applied at router-creation time
+- **wrapper fetch** when the concern belongs to a layout boundary
+- **route fetch** when the concern is route-local
+
+### Example: auth
+
+```tsx
+import { redirect, type AnyRoute } from "@canonical/router-core";
+
+function withAuth(loginPath: string) {
+ return (currentRoute: TRoute): TRoute => {
+ if (currentRoute.url !== "/account") {
+ return currentRoute;
+ }
+
+ return {
+ ...currentRoute,
+ fetch: async (params, search, context) => {
+ if (search.auth !== "1") {
+ redirect(`${loginPath}?from=/account`, 302);
+ }
+
+ return currentRoute.fetch?.(params, search, context);
+ },
+ } as TRoute;
+ };
+}
+```
+
+See [ROUTER_MIDDLEWARE_COOKBOOK.md](ROUTER_MIDDLEWARE_COOKBOOK.md) for more patterns.
+
+## 6. Replace router context with `RouterProvider`
+
+```tsx
+import { Outlet, RouterProvider } from "@canonical/router-react";
+
+export default function Application() {
+ return (
+
+
+
+ );
+}
+```
+
+## 7. Replace TanStack `Link` with Canonical `Link`
+
+```tsx
+import { Link } from "@canonical/router-react";
+
+ params={{ team: "web" }} to="account">
+ Account
+
+```
+
+This keeps route-name typing and adds hover prefetch.
+
+## 8. SSR migration
+
+### TanStack approach
+
+- run loaders on the server
+- serialize router state
+- hydrate on the client
+
+### Canonical approach
+
+The same flow exists, but is explicit:
+
+```tsx
+const serverRouter = createRouter(routes);
+await serverRouter.load("/account/web");
+const initialData = serverRouter.dehydrate();
+```
+
+Client side:
+
+```tsx
+const router = createHydratedRouter(routes);
+```
+
+Or pass `hydratedState` manually to `createRouter()`.
+
+## 9. Error boundaries
+
+TanStack route error boundaries map to:
+
+- route-level `error`
+- wrapper-level `error`
+
+Use route `error` when the fallback is local to one route. Use wrapper `error` when the fallback belongs to a layout or shell.
+
+## Migration checklist
+
+- [ ] Flatten the route tree into a `RouteMap`
+- [ ] Convert parent/layout routes into wrappers
+- [ ] Move loaders into `fetch()`
+- [ ] Move route components into `content`
+- [ ] Move redirects to static redirect routes or `redirect()`
+- [ ] Replace `Link`/`Outlet` imports with `@canonical/router-react`
+- [ ] Add `RouterProvider`
+- [ ] Replace SSR dehydration with `dehydrate()` and `createHydratedRouter()`
+- [ ] Move auth/i18n/timing concerns into middleware or wrapper fetches
+
+## Reference implementation
+
+See [apps/react/boilerplate-vite](../../apps/react/boilerplate-vite) for a working SSR React example using the Canonical router stack.
diff --git a/docs/how-to-guides/ROUTER_MIDDLEWARE_COOKBOOK.md b/docs/how-to-guides/ROUTER_MIDDLEWARE_COOKBOOK.md
new file mode 100644
index 000000000..a2c680205
--- /dev/null
+++ b/docs/how-to-guides/ROUTER_MIDDLEWARE_COOKBOOK.md
@@ -0,0 +1,189 @@
+# Router middleware cookbook
+
+Middleware in `@canonical/router-core` transforms routes once, before the router is created. Use middleware when a concern should apply consistently across a route map without changing every route declaration by hand.
+
+## When to use middleware
+
+Use middleware for:
+
+- auth redirects
+- locale prefixes or locale-aware fetch setup
+- timing or tracing
+- shared wrapper or error-boundary policy
+
+Avoid middleware when the concern is only relevant to a single route. In that case, keep it in the route `fetch()` or `content`.
+
+## Shape of a middleware
+
+```ts
+import type { AnyRoute } from "@canonical/router-core";
+
+function middlewareExample() {
+ return (currentRoute: TRoute): TRoute => {
+ return currentRoute;
+ };
+}
+```
+
+## `withAuth(loginPath)`
+
+Use this when a route should redirect anonymous visitors before loading protected data.
+
+```ts
+import { redirect, type AnyRoute } from "@canonical/router-core";
+
+function withAuth(loginPath: string) {
+ const protectedPaths = new Set(["/account", "/settings"]);
+
+ return (currentRoute: TRoute): TRoute => {
+ if (!protectedPaths.has(currentRoute.url)) {
+ return currentRoute;
+ }
+
+ const currentFetch = currentRoute.fetch;
+
+ return {
+ ...currentRoute,
+ fetch: async (params, search, context) => {
+ const record = search as Record;
+
+ if (record.auth !== "1") {
+ const from = currentRoute.render((params ?? {}) as Record);
+ redirect(`${loginPath}?from=${encodeURIComponent(from)}`, 302);
+ }
+
+ return currentFetch?.(params, search, context);
+ },
+ } as TRoute;
+ };
+}
+```
+
+### Rationale
+
+- keeps auth policy centralized
+- preserves typed route helpers
+- avoids duplicating redirect logic in every protected route
+
+## `withI18n(defaultLocale)`
+
+Use this when your app prefixes routes or injects locale context into loaders.
+
+```ts
+import type { AnyRoute } from "@canonical/router-core";
+
+function withI18n(defaultLocale: string) {
+ return (currentRoute: TRoute): TRoute => {
+ return {
+ ...currentRoute,
+ url: `/:locale${currentRoute.url === "/" ? "" : currentRoute.url}`,
+ fetch: currentRoute.fetch
+ ? async (params, search, context) => {
+ const locale = params.locale ?? defaultLocale;
+ return currentRoute.fetch?.(
+ params,
+ { ...search, locale },
+ context,
+ );
+ }
+ : undefined,
+ } as TRoute;
+ };
+}
+```
+
+### Rationale
+
+- one place to enforce locale-aware URLs
+- useful for boilerplates and generators
+- works with both route fetches and wrapper fetches
+
+## `withTiming(report)`
+
+Use this to measure route loader duration.
+
+```ts
+import type { AnyRoute } from "@canonical/router-core";
+
+function withTiming(
+ report: (event: { route: string; durationMs: number }) => void,
+) {
+ return (currentRoute: TRoute): TRoute => {
+ if (!currentRoute.fetch) {
+ return currentRoute;
+ }
+
+ return {
+ ...currentRoute,
+ fetch: async (params, search, context) => {
+ const startedAt = performance.now();
+
+ try {
+ return await currentRoute.fetch?.(params, search, context);
+ } finally {
+ report({
+ durationMs: performance.now() - startedAt,
+ route: currentRoute.url,
+ });
+ }
+ },
+ } as TRoute;
+ };
+}
+```
+
+### Rationale
+
+- keeps instrumentation orthogonal to route logic
+- easy to disable in tests
+- works for analytics, tracing, and SLO dashboards
+
+## `withErrorBoundary(wrapperDef)`
+
+Use this when multiple routes should share the same wrapper-level error experience.
+
+```ts
+import { group, wrapper, type AnyRoute } from "@canonical/router-core";
+
+const shellBoundary = wrapper({
+ id: "shell:error-boundary",
+ component: ({ children }) => children,
+ error: ({ status }) => `Shell error ${status}`,
+});
+
+function withErrorBoundary() {
+ return (currentRoute: TRoute): TRoute => {
+ return {
+ ...currentRoute,
+ wrappers: [shellBoundary, ...currentRoute.wrappers],
+ } as TRoute;
+ };
+}
+```
+
+### Rationale
+
+- consistent fallback behavior
+- keeps route declarations focused on content and data
+- can be layered with layout wrappers
+
+## Composition order
+
+Middleware runs in array order. Start with broad URL policy, then auth, then instrumentation.
+
+```ts
+const router = createRouter(routes, {
+ middleware: [withI18n("en"), withAuth("/login"), withTiming(report)],
+});
+```
+
+## Rules of thumb
+
+- return the original route unchanged when the rule does not apply
+- preserve `currentRoute.fetch` when wrapping loader logic
+- prefer middleware for cross-cutting policy, wrappers for layout and shared UI
+- document any redirect or URL-shape changes clearly for consumers
+
+## Reference implementation
+
+The live auth example is in [apps/react/boilerplate-vite/src/routes.tsx](../../apps/react/boilerplate-vite/src/routes.tsx).
diff --git a/docs/references/ROUTER_API.md b/docs/references/ROUTER_API.md
new file mode 100644
index 000000000..665a596cf
--- /dev/null
+++ b/docs/references/ROUTER_API.md
@@ -0,0 +1,1040 @@
+# Router API reference
+
+This document is the real API reference for `@canonical/router-core` and `@canonical/router-react`.
+
+Use it when you already understand the mental model and want to answer one of these questions quickly:
+
+- Which function should I call?
+- What does a router instance expose?
+- Which type should I reach for in app code?
+- How does the React package layer on top of the core package?
+
+For tutorial-style guidance, start with:
+
+- [packages/runtime/router/README.md](../../packages/runtime/router/README.md)
+- [packages/react/router/README.md](../../packages/react/router/README.md)
+- [docs/how-to-guides/MIGRATE_FROM_TANSTACK_ROUTER.md](../how-to-guides/MIGRATE_FROM_TANSTACK_ROUTER.md)
+- [docs/how-to-guides/ROUTER_MIDDLEWARE_COOKBOOK.md](../how-to-guides/ROUTER_MIDDLEWARE_COOKBOOK.md)
+
+## `@canonical/router-core`
+
+`@canonical/router-core` owns route definitions, matching, loading, navigation intents, router state, dehydration, and optional accessibility orchestration.
+
+### Route construction
+
+#### `route(definition)`
+
+Creates a typed route definition and attaches a path codec.
+
+Supports two shapes:
+
+```ts
+route({
+ url: "/users/:id",
+ content,
+ fetch,
+ search,
+ error,
+ wrappers,
+});
+
+route({
+ url: "/old-path",
+ redirect: "/new-path",
+ status: 301,
+ wrappers,
+});
+```
+
+Behavior:
+
+- adds a `parse(url)` function that returns typed params or `null`
+- adds a `render(params)` function that builds the concrete pathname
+- normalizes `wrappers` to an array
+- preserves either the data-route or redirect-route shape
+
+Use `route()` for every route. It is the only route constructor.
+
+##### Exact route authoring shapes
+
+`route()` and `wrapper()` are driven by these contracts.
+
+```ts
+interface NavigationContext {
+ readonly signal: AbortSignal;
+}
+
+interface RouteContentProps {
+ readonly params: TParams;
+ readonly search: TSearch;
+ readonly data: TData;
+}
+
+interface RouteErrorProps {
+ readonly error: unknown;
+ readonly status: number;
+ readonly params: RouteParams;
+ readonly search: TSearch;
+ readonly url: string;
+}
+
+interface DataRouteInput {
+ readonly url: TPath;
+ readonly content: (props: RouteContentProps<...>) => TRendered;
+ readonly fetch?: (
+ params: RouteParams,
+ search: InferSearch,
+ context: NavigationContext,
+ ) => Promise;
+ readonly search?: TSearchSchema;
+ readonly error?: (props: RouteErrorProps>) => TRendered;
+ readonly wrappers?: readonly AnyWrapper[];
+}
+
+interface RedirectRouteInput {
+ readonly url: TPath;
+ readonly redirect: string;
+ readonly status: 301 | 308;
+ readonly wrappers?: readonly AnyWrapper[];
+}
+```
+
+Practical reading:
+
+- `content` always receives `params`, `search`, and `data`
+- `fetch` always receives `params`, `search`, and an abort `signal`
+- route `error` receives the thrown error, resolved status, params, search, and failing URL
+- static redirect routes are declaration-time redirects; dynamic redirects belong in `fetch()` via `redirect()`
+
+##### Lazy content and `content.preload`
+
+The content leg is the code-splitting boundary. Assign a dynamic import to `content` and attach a `preload` method so the router can prefetch the module on hover:
+
+```ts
+const lazyContent = Object.assign(
+ (props: RouteContentProps<...>) => import("./Page.js").then(m => m.default(props)),
+ { preload: () => import("./Page.js") },
+);
+
+route({ url: "/page", content: lazyContent });
+```
+
+When `prefetch()` is called (e.g. on link hover), the router calls `content.preload()` if present. The returned promise resolves the module so subsequent renders hit the module cache instead of triggering a network request.
+
+#### `wrapper(definition)`
+
+Creates a nominal wrapper definition.
+
+```ts
+const shell = wrapper({
+ id: "app:shell",
+ component: ({ children }) => children,
+ fetch,
+ error,
+});
+```
+
+Wrappers are reusable layout and boundary units. A wrapper can:
+
+- render shared UI around matched content
+- fetch shared data before route rendering
+- provide a shared error fallback
+
+Exact shape:
+
+```ts
+interface WrapperComponentProps {
+ readonly data: TData;
+ readonly children: TRendered;
+}
+
+interface WrapperErrorProps {
+ readonly error: unknown;
+ readonly status: number;
+ readonly params: TParams;
+ readonly search: TSearch;
+ readonly url: string;
+}
+
+interface WrapperDefinition {
+ readonly id: string;
+ readonly component: (props: WrapperComponentProps) => TRendered;
+ readonly fetch?: (
+ params: RouteParamValues,
+ context: NavigationContext,
+ ) => Promise;
+ readonly error?: (props: WrapperErrorProps) => TRendered;
+}
+```
+
+Important difference from route loaders:
+
+- wrapper `fetch` receives `params` and `context`
+- route `fetch` receives `params`, `search`, and `context`
+- wrapper data is stored by wrapper id in `wrapperData`
+
+#### `group(wrapper, routes)`
+
+Prepends one wrapper to every route in a flat route list.
+
+```ts
+const [dashboard, reports] = group(shell, [
+ route({ url: "/dashboard", content: Dashboard }),
+ route({ url: "/reports", content: Reports }),
+] as const);
+```
+
+Use `group()` when several flat routes should share the same wrapper stack.
+
+#### `applyMiddleware(routes, middleware)`
+
+Applies route endomorphisms to a route list.
+
+```ts
+const nextRoutes = applyMiddleware(routes, [withI18n(), withAuth()]);
+```
+
+Behavior:
+
+- middleware runs outermost-first in array order
+- each middleware receives one route and returns one route
+- useful for auth, i18n, timing, or generated route transforms
+
+### Redirects and errors
+
+#### `redirect(to, status?)`
+
+Throws a redirect value during route or wrapper loading.
+
+```ts
+redirect("/login", 302);
+```
+
+Accepted statuses: `301 | 302 | 307 | 308`.
+
+Use this inside `fetch()` when the redirect depends on runtime state.
+
+#### `Redirect`
+
+The redirect error class thrown by `redirect()`.
+
+You normally do not instantiate this directly in app code.
+
+#### `StatusResponse`
+
+Error helper with an HTTP-like status code and optional typed data.
+
+```ts
+new StatusResponse(status: number, data?: TData)
+```
+
+Properties: `status: number`, `data: TData | undefined`.
+
+Use it when a route or wrapper should fail with a structured status that can be rendered by route- or wrapper-level error boundaries.
+
+### Router creation and adapters
+
+#### `createRouter(routes, options?)`
+
+Creates the router instance.
+
+```ts
+const router = createRouter(routes, {
+ adapter,
+ middleware,
+ notFound,
+ hydratedState,
+ initialUrl,
+ accessibility,
+});
+```
+
+Key behavior:
+
+- applies route middleware before setup
+- sorts routes by matching priority
+- wires an adapter when provided
+- auto-initializes browser accessibility helpers when browser globals exist
+- can hydrate previously loaded server state
+- returns a typed `Router`
+
+Exact option shape:
+
+```ts
+interface RouterOptions {
+ readonly adapter?: PlatformAdapter;
+ readonly accessibility?: RouterAccessibilityOptions;
+ readonly hydratedState?: RouterDehydratedState;
+ readonly initialUrl?: string | URL;
+ readonly middleware?: readonly RouteMiddleware[];
+ readonly notFound?: TNotFound;
+}
+```
+
+Typical use:
+
+- browser app: provide `adapter`, or use `createHydratedRouter()` in React
+- tests: provide `createMemoryAdapter()`
+- SSR hydrate: provide `hydratedState`
+- app-wide transforms: provide `middleware`
+- custom 404 screen: provide `notFound`
+
+#### `createBrowserAdapter(windowLike?)`
+
+Creates a `PlatformAdapter` backed by `window.history` and `popstate`.
+
+Use this in browser-only or framework-integrated setups. `@canonical/router-react` does this for you in `createHydratedRouter()`.
+
+Adapter contract:
+
+```ts
+interface PlatformAdapter {
+ getLocation(): string | URL;
+ navigate(url: string, options?: { replace?: boolean; state?: unknown }): void;
+ subscribe(callback: (location: string | URL) => void): () => void;
+}
+```
+
+#### `createMemoryAdapter(initialUrl?)`
+
+Creates an in-memory history adapter.
+
+Useful for:
+
+- tests
+- demos
+- non-browser environments
+- deterministic navigation assertions
+
+The returned adapter also exposes `back()` and `forward()`.
+
+```ts
+interface MemoryAdapter extends PlatformAdapter {
+ back(): void;
+ forward(): void;
+}
+```
+
+#### `createServerAdapter(initialUrl)`
+
+Creates a static adapter for one server request URL.
+
+Use it when you want router matching and loading against an explicit server request location, without client-side navigation.
+
+#### `createRouterStore(resolveMatch, initialUrl?)`
+
+Low-level state store used by the router implementation.
+
+```ts
+createRouterStore(
+ resolveMatch: (input: string | URL) => RouterMatch | null,
+ initialUrl: string | URL = "/",
+): RouterStore
+```
+
+Most app code should not call this directly. Reach for it only when you are extending router internals or building alternate bindings.
+
+#### `createSubject()`
+
+Minimal observable primitive used by store internals.
+
+#### `createTrackedLocation()`
+
+Utility for tracking which location properties a consumer accessed. This supports fine-grained subscriptions in bindings.
+
+### The `Router` instance
+
+`createRouter()` returns a `Router` with these high-value members.
+
+#### Properties
+
+| Member | Meaning |
+|---|---|
+| `routes` | The resolved typed route map. |
+| `notFound` | Optional not-found route definition. |
+| `adapter` | Active platform adapter or `null`. |
+| `store` | The underlying `RouterStore`. |
+
+The router instance is the runtime boundary between route definitions and UI bindings.
+
+- core code can call it directly
+- React bindings subscribe to it
+- SSR uses `load()`, `dehydrate()`, and `render()`
+
+#### Lookup and state
+
+| Member | Meaning |
+|---|---|
+| `getRoute(name)` | Returns one route definition by name. |
+| `getState()` | Returns the current router state. |
+| `getTrackedLocation(onAccess)` | Returns a tracked location proxy for fine-grained subscriptions. |
+| `match(url)` | Returns the current route match without loading data. |
+
+#### Navigation and loading
+
+| Member | Meaning |
+|---|---|
+| `buildPath(name, options?)` | Builds a concrete href from route name, params, search, and hash. Options are required when the route has path params (`HasParams extends true`). |
+| `navigate(name, options?)` | Builds a typed navigation intent and performs adapter navigation when available. Options are required when the route has path params. Supports `replace: boolean` to use `replaceState` instead of `pushState`. |
+| `prefetch(name, options?)` | Preloads the route module and data. Options are required when the route has path params. |
+| `load(url)` | Matches and resolves route data plus wrapper data for a URL. |
+| `render(result?)` | Renders the currently loaded match tree. |
+
+#### SSR and lifecycle
+
+| Member | Meaning |
+|---|---|
+| `dehydrate()` | Serializes the currently loaded result, or returns `null`. |
+| `hydrate(state)` | Restores a previous load result into the router. |
+| `dispose()` | Tears down subscriptions and router-owned resources. |
+
+#### Subscriptions
+
+| Member | Meaning |
+|---|---|
+| `subscribe(listener)` | Subscribe to any router snapshot change. |
+| `subscribeToNavigation(listener)` | Subscribe only to navigation state changes. |
+| `subscribeToSearchParam(key, listener)` | Subscribe only to one query-string key. |
+
+#### Exact runtime state shapes
+
+```ts
+type RouterNavigationState = "idle" | "loading";
+
+interface RouterLocationState {
+ readonly hash: string;
+ readonly href: string;
+ readonly pathname: string;
+ readonly searchParams: URLSearchParams;
+ readonly status: number;
+ readonly url: URL;
+}
+
+interface RouterState {
+ readonly location: RouterLocationState;
+ readonly match: RouterMatch | null;
+ readonly navigation: {
+ readonly state: RouterNavigationState;
+ };
+}
+
+interface RouterSnapshot extends RouterLocationState {
+ readonly match: RouterMatch | null;
+ readonly navigationState: RouterNavigationState;
+}
+```
+
+Practical reading:
+
+- `getState()` returns nested state under `location` and `navigation`
+- `subscribe()` listeners observe a flattened `RouterSnapshot`
+- `status` lives on location state and mirrors the last resolved load status
+
+### Route and wrapper shapes
+
+#### `DataRouteInput`
+
+Input shape for a normal route.
+
+Important fields:
+
+| Field | Meaning |
+|---|---|
+| `url` | Route pattern such as `"/users/:id"`. |
+| `content` | Render function for the route. |
+| `fetch` | Optional loader. Receives `params`, typed `search`, and `NavigationContext`. |
+| `search` | Optional schema used to infer typed query params. |
+| `error` | Optional route-level error renderer. |
+| `wrappers` | Optional wrapper stack. |
+
+#### `RedirectRouteInput`
+
+Input shape for a static redirect route.
+
+Important fields:
+
+| Field | Meaning |
+|---|---|
+| `url` | Source pattern. |
+| `redirect` | Target location. |
+| `status` | `301` or `308`. |
+| `wrappers` | Optional wrapper stack. |
+
+#### `WrapperDefinition`
+
+Reusable wrapper contract.
+
+Important fields:
+
+| Field | Meaning |
+|---|---|
+| `id` | Stable wrapper identifier. Must be unique across the route set. |
+| `component` | Wrapper renderer around child content. |
+| `fetch` | Optional shared loader. |
+| `error` | Optional shared error renderer. |
+
+### Matching and load results
+
+#### `RouterMatch`
+
+Union of:
+
+- `DataRouteMatch`
+- `RedirectRouteMatch`
+- `NotFoundRouteMatch`
+
+Every match includes:
+
+- `route`
+- `params`
+- `search`
+- `pathname`
+- `url`
+
+Variant-specific fields:
+
+```ts
+interface DataRouteMatch {
+ readonly kind: "route";
+ readonly name: TName;
+ readonly status: 200;
+}
+
+interface RedirectRouteMatch {
+ readonly kind: "redirect";
+ readonly name: TName;
+ readonly redirectTo: string;
+ readonly status: TRoute["status"];
+}
+
+interface NotFoundRouteMatch {
+ readonly kind: "not-found";
+ readonly name: null;
+ readonly status: 404;
+}
+```
+
+#### `RouterLoadResult`
+
+Result returned by `load()` and `hydrate()`.
+
+Important fields:
+
+| Field | Meaning |
+|---|---|
+| `match` | The resolved match or `null`. |
+| `status` | Final HTTP-like status. |
+| `routeData` | Resolved route loader data. |
+| `wrapperData` | Resolved wrapper loader data keyed by wrapper id. |
+| `error` | Caught error, if any. |
+| `errorBoundary` | Indicates whether a route or wrapper boundary handled the failure. |
+| `location` | Final resolved location state. |
+| `dehydrate()` | Serializes the load result for SSR hydration. |
+
+Exact shape:
+
+```ts
+interface RouterLoadResult {
+ dehydrate(): RouterDehydratedState;
+ readonly error: unknown;
+ readonly errorBoundary: {
+ readonly type: "route" | "wrapper";
+ readonly wrapperId: string | null;
+ } | null;
+ readonly location: RouterLocationState;
+ readonly match: RouterMatch | null;
+ readonly routeData: unknown;
+ readonly status: number;
+ readonly wrapperData: Readonly>;
+}
+```
+
+Interpretation:
+
+- `errorBoundary.type === "route"` means the route-level error renderer handled the failure
+- `errorBoundary.type === "wrapper"` means one wrapper error renderer handled it
+- `wrapperId` identifies which wrapper boundary handled the error
+
+#### `RouterDehydratedState`
+
+Serialized SSR payload.
+
+Important fields:
+
+| Field | Meaning |
+|---|---|
+| `href` | Original loaded href. |
+| `kind` | `"route" | "not-found" | "unmatched"`. |
+| `routeId` | Matched route name or `null`. |
+| `routeData` | Serialized route data. |
+| `wrapperData` | Serialized wrapper data. |
+| `status` | Final status code. |
+
+### Configuration types
+
+#### `RouterOptions`
+
+Primary router configuration object.
+
+| Field | Meaning |
+|---|---|
+| `adapter` | Platform adapter implementation. |
+| `accessibility` | Accessibility integrations and overrides. |
+| `hydratedState` | Previous server load state to hydrate. |
+| `initialUrl` | Explicit initial URL for routers without an adapter. |
+| `middleware` | Route middleware array. |
+| `notFound` | Optional not-found route. |
+
+#### `RouterAccessibilityOptions`
+
+Overrides for browser navigation affordances.
+
+| Field | Meaning |
+|---|---|
+| `document` | Document-like object used by accessibility helpers. |
+| `focusManager` | Custom focus manager or `false` to disable. |
+| `getTitle` | Title resolver for route announcements. |
+| `routeAnnouncer` | Custom announcer or `false` to disable. |
+| `scrollManager` | Custom scroll manager or `false` to disable. |
+| `viewTransition` | Custom transition manager or `false` to disable. |
+
+### Type index by intent
+
+Use these types when building app code or helpers.
+
+#### Route authoring
+
+| Type | When to use it |
+|---|---|
+| `RouteInput` | Accept either a data route or redirect route. |
+| `DataRouteInput` | Constrain an API to normal content routes. |
+| `RedirectRouteInput` | Constrain an API to static redirects. |
+| `RouteDefinition` | Accept a normalized route returned by `route()`. |
+| `WrapperInput` / `WrapperDefinition` | Accept or return wrappers. |
+| `RouteContentProps` | Type route render props. |
+| `RouteErrorProps` | Type route error render props. |
+| `WrapperComponentProps` | Type wrapper render props. |
+| `WrapperErrorProps` | Type wrapper error props. |
+
+#### Type extraction helpers
+
+| Type | Meaning |
+|---|---|
+| `RouteMap` | A flat record of route names to routes. |
+| `RouteName` | String union of route names. |
+| `RouteOf` | Route type for one route name. |
+| `ParamsOf` | Param object for one route. |
+| `SearchOf` | Search type inferred from the route schema. |
+| `DataOf` | Loader data type for one route. |
+| `PathBuildOptions` | Options accepted by `buildPath()`. |
+| `NavigationIntent` | Typed result of `navigate()`. |
+
+#### Middleware and grouping helpers
+
+| Type | Meaning |
+|---|---|
+| `RouteMiddleware` | One route-to-route transform. |
+| `GroupedRoutes` | Output of `group()`. |
+| `PrependWrapper` | Result type when a wrapper is prepended. |
+
+#### Matching and store internals
+
+| Type | Meaning |
+|---|---|
+| `RouterState` | Full router state tree. |
+| `RouterSnapshot` | Snapshot returned to subscribers. |
+| `RouterStore` | Store contract used by the router and bindings. |
+| `TrackedLocation` | Proxy-backed location used for selective subscriptions. |
+| `PlatformAdapter` | Adapter contract for browser, memory, and server runtimes. |
+| `MemoryAdapter` | Platform adapter with `back()` and `forward()`. |
+
+#### Accessibility
+
+| Type | Meaning |
+|---|---|
+| `RouterAccessibilityContext` | Input passed to `getTitle`. |
+| `FocusManagerLike` | Focus handoff contract. |
+| `RouteAnnouncerLike` | Screen-reader announcement contract. |
+| `ScrollManagerLike` | Scroll restoration contract. |
+| `ViewTransitionManagerLike` | View transition orchestration contract. |
+| `RouterAccessibilityDocumentLike` | Minimal document shape for accessibility helpers. |
+
+#### Utility and schema types
+
+| Type | Meaning |
+|---|---|
+| `StandardSchemaLike` | Minimal standard-schema contract for typed search params. |
+| `InferSearch` | Search output inferred from a search schema. |
+| `ParamNames` / `RouteParams` | Param extraction from path strings. |
+| `BivariantCallback` | Internal helper for callback variance. |
+| `UnionToIntersection` | Internal helper used to build typed overload-like helpers. |
+| `StripParamModifier` | Internal helper for path param parsing. |
+| `HasParams` | Whether a route requires params. |
+| `BuildPathFn` / `NavigateFn` / `PrefetchFn` | Typed function shapes used on `Router`. |
+
+### What is actually exported from `@canonical/router-core`
+
+Runtime exports:
+
+- `applyMiddleware`
+- `createBrowserAdapter`
+- `createMemoryAdapter`
+- `createRouter`
+- `createRouterStore`
+- `createServerAdapter`
+- `createSubject`
+- `createTrackedLocation`
+- `FocusManager`
+- `group`
+- `Redirect`
+- `redirect`
+- `route`
+- `RouteAnnouncer`
+- `ScrollManager`
+- `StatusResponse`
+- `ViewTransitionManager`
+- `wrapper`
+
+In addition, all public types are re-exported from `types.ts`.
+
+## `@canonical/router-react`
+
+`@canonical/router-react` supplies React context, subscriptions, links, outlets, and SSR helpers on top of the core router.
+
+### Components and helpers
+
+#### `RouterProvider`
+
+Places a router into React context.
+
+```tsx
+{children}
+```
+
+Props are defined by `RouterProviderProps`:
+
+| Prop | Meaning |
+|---|---|
+| `router` | The typed router instance. |
+| `children` | Descendant React tree. |
+
+Exact shape:
+
+```ts
+interface RouterProviderProps {
+ readonly children?: ReactNode;
+ readonly router: Router;
+}
+```
+
+#### `Outlet`
+
+Subscribes to the router and renders the active route tree through Suspense.
+
+```tsx
+Loading…} />
+```
+
+Props are defined by `OutletProps`:
+
+| Prop | Meaning |
+|---|---|
+| `fallback` | Optional Suspense fallback while content resolves. |
+
+Exact shape:
+
+```ts
+interface OutletProps {
+ readonly fallback?: ReactNode;
+}
+```
+
+Runtime context:
+
+- `Outlet` subscribes with `useSyncExternalStore()`
+- it calls `router.render()` to obtain the active rendered tree
+- it wraps that tree in `Suspense`
+
+#### `Link`
+
+Typed anchor component.
+
+Behavior:
+
+- computes `href` from route name plus params/search/hash
+- intercepts ordinary left-click navigation
+- preserves modifier-key and `_blank` behavior
+- triggers `prefetch()` on mouse enter
+
+Core props:
+
+| Prop | Meaning |
+|---|---|
+| `to` | Route name. |
+| `params` | Route params when required. |
+| `search` | Query object for the target route. |
+| `hash` | Optional fragment. |
+| `children` | Anchor contents. |
+| native anchor props | Passed through except `href`, which is computed. |
+
+Typed prop shapes are exposed as `LinkBuildOptions` and `LinkProps`.
+
+Exact shapes:
+
+```ts
+type LinkBuildOptions = {
+ readonly hash?: string;
+ readonly search?: SearchOf;
+} & (HasParams extends true
+ ? { readonly params: ParamsOf }
+ : { readonly params?: ParamsOf });
+
+type LinkProps = Omit<
+ AnchorHTMLAttributes,
+ "href"
+> &
+ LinkBuildOptions> & {
+ readonly children?: ReactNode;
+ readonly onClick?: MouseEventHandler;
+ readonly onMouseEnter?: MouseEventHandler;
+ readonly ref?: Ref;
+ readonly to: TName;
+ };
+```
+
+Runtime context:
+
+- `href` is always derived from the router
+- plain left click becomes client-side navigation
+- modified click, non-left click, and `_blank` behave like a normal anchor
+- hover triggers `router.prefetch()` for the computed target
+
+#### `createHydratedRouter(routes, options?)`
+
+Creates a browser-backed router and reads initial state from `window.__INITIAL_DATA__`.
+
+Behavior:
+
+- builds a browser adapter automatically
+- reads the SSR payload from the hydration window
+- forwards all non-adapter `RouterOptions`
+
+Use this for the normal client-side half of SSR.
+
+Exact option shape:
+
+```ts
+interface HydrationWindow {
+ readonly [key: string]: unknown;
+}
+
+interface CreateHydratedRouterOptions
+ extends Omit, "adapter"> {
+ readonly browserWindow?: HydrationWindow;
+}
+```
+
+Runtime context:
+
+- reads initial state from the `@canonical/react-ssr` initial data key
+- constructs the browser adapter internally
+- forwards the rest of the router options to core `createRouter()`
+
+#### `renderToStream(router, url, options?)`
+
+Loads a URL, renders the matched route tree, and returns everything needed for SSR.
+
+Return shape:
+
+| Field | Meaning |
+|---|---|
+| `stream` | Readable stream from React server rendering. |
+| `loadResult` | Router load result for the request. |
+| `initialData` | Dehydrated router payload or `null`. |
+| `bootstrapScriptContent` | Inline script that assigns the hydration payload to the SSR window key. |
+
+Exact shapes:
+
+```ts
+interface RenderToStreamOptions {
+ readonly fallback?: ReactNode;
+}
+
+interface RenderToStreamResult {
+ readonly bootstrapScriptContent: string | null;
+ readonly initialData: RouterDehydratedState | null;
+ readonly loadResult: RouterLoadResult;
+ readonly stream: ReadableStream;
+}
+```
+
+Runtime context:
+
+- calls `router.load(url)` first
+- then calls `router.dehydrate()`
+- then renders the matched route tree inside `RouterProvider` and `Outlet`
+- returns the stream and the payload you need to hydrate on the client
+
+### Hooks
+
+#### `useRouter()`
+
+Returns the router from context.
+
+Throws if no `RouterProvider` is present.
+
+Signature:
+
+```ts
+function useRouter(): Router
+```
+
+#### `useNavigationState()`
+
+Subscribes only to router navigation state and returns `"idle" | "loading"`.
+
+Use it for global progress indicators and button disabling.
+
+Signature:
+
+```ts
+function useNavigationState(): "idle" | "loading"
+```
+
+#### `useRouterState()`
+
+Power-user hook for subscribing to `router.getState()`.
+
+It supports two modes:
+
+- no selector: return the full `RouterState`
+- selector: return only the selected slice
+
+If your selector returns structured objects, pass `isEqual` to preserve the
+previous selection when the new value is semantically unchanged.
+
+Signatures:
+
+```ts
+function useRouterState(): RouterState
+
+function useRouterState(
+ selector: (state: RouterState) => TSelected,
+ options?: {
+ isEqual?: (previous: TSelected, next: TSelected) => boolean;
+ },
+): TSelected
+```
+
+#### `useRoute()`
+
+Returns a tracked location proxy backed by the current router state.
+
+This hook tracks which location properties your component reads and only re-renders when one of those properties changes.
+
+Typical accessed fields include:
+
+- `pathname`
+- `url`
+- `searchParams`
+- `hash`
+
+Signature:
+
+```ts
+function useRoute(): TrackedLocation
+```
+
+Important context:
+
+- despite the name, this hook does **not** return the current route match object
+- it returns tracked location state
+- if you need the current match, read `useRouter().getState().match`
+
+#### `useSearchParam(key)`
+
+Subscribes only to one query-string key and returns its current string value or `null`.
+
+Use this when a component only cares about a single query param and should not re-render for unrelated location changes.
+
+Signature:
+
+```ts
+function useSearchParam(key: string): string | null
+```
+
+#### `useSearchParams()`
+
+Subscribes to all search params or to a fixed subset of keys.
+
+Use it in one of two ways:
+
+- no arguments: return `URLSearchParams` and re-render for any query-string change
+- keyed selection: return an object of selected key/value pairs and re-render
+ only when one of those keys changes
+
+Signatures:
+
+```ts
+function useSearchParams(): URLSearchParams
+
+function useSearchParams(
+ keys: TKeys,
+): Readonly<{ [K in TKeys[number]]: string | null }>
+```
+
+### React package types
+
+| Type | Meaning |
+|---|---|
+| `AnyReactRouter` | Broad router type used inside context plumbing. |
+| `RouterProviderProps` | Props for `RouterProvider`. |
+| `LinkBuildOptions` | Typed `params` / `search` / `hash` inputs for `Link`. |
+| `LinkProps` | Full typed prop bag for `Link`. |
+| `OutletProps` | Props for `Outlet`. |
+| `RenderToStreamOptions` | Options for `renderToStream()`, currently `fallback`. |
+| `RenderToStreamResult` | SSR result contract returned by `renderToStream()`. |
+| `HydrationWindow` | Minimal window-like object used for hydration payload lookup. |
+| `CreateHydratedRouterOptions` | `RouterOptions` plus optional `browserWindow`. |
+| `CreateHydratedRouterWindow` | Alias of `HydrationWindow`. |
+| `HydratedNavigationState` | Alias of core `RouterNavigationState`. |
+| `SearchParamValues` | Mapped values returned by keyed `useSearchParams()`. |
+| `UseRouterStateOptions` | Equality options for `useRouterState()`. |
+
+### What is actually exported from `@canonical/router-react`
+
+Runtime exports:
+
+- `createHydratedRouter`
+- `Link`
+- `Outlet`
+- `RouterProvider`
+- `renderToStream`
+- `useNavigationState`
+- `useRoute`
+- `useRouter`
+- `useRouterState`
+- `useSearchParam`
+- `useSearchParams`
+
+In addition, all public React-facing types are re-exported from `types.ts`.
+
+## Quick decision guide
+
+If you are unsure where to look:
+
+| Need | API |
+|---|---|
+| Define one route | `route()` |
+| Share a shell or boundary | `wrapper()` + `group()` |
+| Add cross-cutting route policy | `applyMiddleware()` |
+| Redirect during loading | `redirect()` |
+| Build the router | `createRouter()` |
+| Run in the browser | `createBrowserAdapter()` or `createHydratedRouter()` |
+| Run in tests | `createMemoryAdapter()` |
+| Run for one server request | `createServerAdapter()` or `renderToStream()` |
+| Render the active route in React | `RouterProvider` + `Outlet` |
+| Create typed links | `Link` |
+| Read router instance in React | `useRouter()` |
+| Read loading state in React | `useNavigationState()` |
+| Read one search param in React | `useSearchParam()` |