@typeroute/router 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,1973 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/strblr/typeroute/master/banner.svg" alt="TypeRoute" />
3
+ </p>
4
+
5
+ <p align="center">
6
+ A type-safe router for React that just works.
7
+ </p>
8
+
9
+ <div align="center">
10
+ <a href="https://www.npmjs.com/package/@typeroute/router">
11
+ <img
12
+ src="https://img.shields.io/npm/v/@typeroute/router?style=flat-square&color=0B0D0F&labelColor=0B0D0F"
13
+ alt="npm version"
14
+ />
15
+ </a>
16
+ <a href="https://www.npmjs.com/package/@typeroute/router">
17
+ <img
18
+ src="https://img.badgesize.io/https://unpkg.com/waymark/dist/index.js?compression=gzip&label=gzip&style=flat-square&color=0B0D0F&labelColor=0B0D0F"
19
+ alt="gzip size"
20
+ />
21
+ </a>
22
+ <a href="https://www.npmjs.com/package/@typeroute/router">
23
+ <img
24
+ src="https://img.shields.io/npm/dm/@typeroute/router?style=flat-square&color=0B0D0F&labelColor=0B0D0F"
25
+ alt="downloads"
26
+ />
27
+ </a>
28
+ <a href="https://github.com/strblr/typeroute/blob/master/LICENSE">
29
+ <img
30
+ src="https://img.shields.io/npm/l/@typeroute/router?style=flat-square&color=0B0D0F&labelColor=0B0D0F"
31
+ alt="license"
32
+ />
33
+ </a>
34
+ <a href="https://github.com/sponsors/strblr">
35
+ <img
36
+ src="https://img.shields.io/github/sponsors/strblr?style=flat-square&color=0B0D0F&labelColor=0B0D0F"
37
+ alt="sponsors"
38
+ />
39
+ </a>
40
+ </div>
41
+
42
+ <p align="center">
43
+ ๐Ÿ“– <a href="https://typeroute.com">Documentation</a> ยท ๐ŸŽฎ <a href="https://stackblitz.com/edit/typeroute-demo?file=src%2Fapp.tsx">Live playground</a>
44
+ </p>
45
+
46
+ ---
47
+
48
+ TypeRoute is a routing library for React built around three core ideas: **type safety**, **simplicity**, and **minimal overhead**.
49
+
50
+ - ๐Ÿ”’ **Fully type-safe** - Complete TypeScript inference for routes, path params, search params, and more
51
+ - โšก **Zero config** - No build plugins, no CLI, no codegen, no config files, very low boilerplate
52
+ - ๐Ÿชถ **4kB gzipped** - Extremely lightweight, dependency included
53
+ - ๐Ÿค **Familiar API** - If you've used React Router or TanStack Router, you'll feel at home
54
+ - ๐ŸŽฏ **Feature packed** - Search param validation, lazy loading, data preloading, SSR, error boundaries, etc.
55
+ - ๐Ÿง  **Not vibe-coded** - Built with careful design and attention to detail by a human
56
+ - โœจ **Just works** - Simple setup, predictable behavior that never gets in your way
57
+
58
+ ---
59
+
60
+ # Comparison
61
+
62
+ | Feature | TypeRoute | React Router | TanStack Router | Wouter |
63
+ | -------------------------------- | :-------: | :----------: | :-------------: | :----: |
64
+ | **Bundle size (gzip)**\* | ~4kB | ~26kB+ | ~19kB+ | ~2.2kB |
65
+ | **Zero config**\* | โœ… | โŒ | โš ๏ธ | โœ… |
66
+ | **Full type inference**\* | โœ… | โš ๏ธ | โœ… | โŒ |
67
+ | **Nested routes** | โœ… | โœ… | โœ… | โœ… |
68
+ | **Search param validation**\* | โœ… | โŒ | โœ… | โŒ |
69
+ | **Lazy loading** | โœ… | โœ… | โœ… | โŒ |
70
+ | **Data preloading** | โœ… | โœ… | โœ… | โŒ |
71
+ | **Built-in error boundaries** | โœ… | โœ… | โœ… | โŒ |
72
+ | **Built-in suspense boundaries** | โœ… | โŒ | โœ… | โŒ |
73
+ | **Link preloading strategies** | โœ… | โœ… | โœ… | โŒ |
74
+ | **Active link detection** | โœ… | โœ… | โœ… | โš ๏ธ |
75
+ | **Browser/Hash/Memory history** | โœ… | โœ… | โœ… | โœ… |
76
+ | **SSR support** | โœ… | โœ… | โœ… | โœ… |
77
+ | **Route middlewares**\* | โœ… | โŒ | โŒ | โŒ |
78
+ | **Route handles (metadata)** | โœ… | โœ… | โœ… | โŒ |
79
+ | **Route match ranking**\* | โœ… | โœ… | โœ… | โŒ |
80
+ | **View transitions** | โœ… | โœ… | โœ… | โœ… |
81
+ | **Devtools** | โœ… | โš ๏ธ | โœ… | โŒ |
82
+ | **Navigation blockers** | ๐Ÿ”จ | โœ… | โœ… | โŒ |
83
+ | **File-based routing** | โŒ | โœ… | โœ… | โŒ |
84
+ | **React Native** | โŒ | โœ… | โŒ | โŒ |
85
+
86
+ <details>
87
+ <summary><b>Comparison notes</b></summary>
88
+
89
+ <br />
90
+
91
+ If you believe there's a mistake in the comparison table, please [open an issue](https://github.com/strblr/typeroute/issues) or [submit a PR](https://github.com/strblr/typeroute/pulls) and it will be fixed.
92
+
93
+ - โš ๏ธ indicates the feature is only partially supported, supported with heavy boilerplate, or requires external libraries.
94
+ - ๐Ÿ”จ indicates the feature is not yet ready but being worked on.
95
+ - **Bundle sizes** are approximate gzipped values. React Router and TanStack Router sizes can vary significantly based on imports and versions; TypeRoute's ~4kB includes its single ~0.4kB dependency ([regexparam](https://github.com/lukeed/regexparam)), before any tree shaking. Wouter is the smallest option but lacks features.
96
+ - **Zero config** means no CLI tools, build plugins, code generation, or configuration files are required. React Router requires its typegen CLI or bundler plugin for full type safety. Same with TanStack Router for file-based routing. You can use code-based routing but it's more boilerplate.
97
+ - **Full type inference** refers to automatic TypeScript inference for routes, params, search params, and navigation without manual type annotations.
98
+ - **Search params validation** refers to built-in support for validating and typing URL search parameters. Wouter provides `useSearch()` but no validation layer. Same with React Router and `useSearchParams`.
99
+ - **Route middlewares** are reusable configuration bundles (search validation, handles, preload functions, components) that can be applied to multiple routes. This is a TypeRoute-specific feature.
100
+ - **Route match ranking** automatically picks the most specific route when multiple patterns match (e.g., `/users/new` wins over `/users/:id`). Without ranking, route definition order matters.
101
+
102
+ </details>
103
+
104
+ ---
105
+
106
+ # Table of contents
107
+
108
+ - [Comparison](#comparison)
109
+ - [Showcase](#showcase)
110
+ - [Installation](#installation)
111
+ - [Defining routes](#defining-routes)
112
+ - [Nested routes and layouts](#nested-routes-and-layouts)
113
+ - [Setting up the router](#setting-up-the-router)
114
+ - [Code organization](#code-organization)
115
+ - [Path params](#path-params)
116
+ - [Search params](#search-params)
117
+ - [Basic usage](#basic-usage)
118
+ - [JSON-first approach](#json-first-approach)
119
+ - [Inheritance](#inheritance)
120
+ - [Idempotency requirement](#idempotency-requirement)
121
+ - [Navigation](#navigation)
122
+ - [The Link component](#the-link-component)
123
+ - [Active state detection](#active-state-detection)
124
+ - [Route preloading](#route-preloading)
125
+ - [Programmatic navigation](#programmatic-navigation)
126
+ - [Declarative navigation](#declarative-navigation)
127
+ - [Lazy loading](#lazy-loading)
128
+ - [Data preloading](#data-preloading)
129
+ - [Error boundaries](#error-boundaries)
130
+ - [Suspense boundaries](#suspense-boundaries)
131
+ - [Route handles](#route-handles)
132
+ - [Middlewares](#middlewares)
133
+ - [Route matching and ranking](#route-matching-and-ranking)
134
+ - [History implementations](#history-implementations)
135
+ - [Devtools](#devtools)
136
+ - [Cookbook](#cookbook)
137
+ - [Quick start example](#quick-start-example)
138
+ - [Server-side rendering (SSR)](#server-side-rendering-ssr)
139
+ - [Scroll to top on navigation](#scroll-to-top-on-navigation)
140
+ - [Matching a route anywhere](#matching-a-route-anywhere)
141
+ - [Global link configuration](#global-link-configuration)
142
+ - [History middleware](#history-middleware)
143
+ - [View transitions](#view-transitions)
144
+ - [API reference](#api-reference)
145
+ - [Router class](#router-class)
146
+ - [Route class](#route-class)
147
+ - [Middleware](#middleware)
148
+ - [Hooks](#hooks)
149
+ - [Components](#components)
150
+ - [History interface](#history-interface)
151
+ - [Types](#types)
152
+ - [Roadmap](#roadmap)
153
+ - [License](#license)
154
+
155
+ ---
156
+
157
+ # Showcase
158
+
159
+ Here's what routing looks like with TypeRoute:
160
+
161
+ ```tsx
162
+ import { route, RouterRoot, Outlet, Link, useParams } from "@typeroute/router";
163
+
164
+ // Layout
165
+ const layout = route("/").component(() => (
166
+ <div>
167
+ <nav>
168
+ <Link to="/">Home</Link>
169
+ <Link to="/users/:id" params={{ id: "42" }}>
170
+ User
171
+ </Link>
172
+ </nav>
173
+ <Outlet />
174
+ </div>
175
+ ));
176
+
177
+ // Pages
178
+ const home = layout.route("/").component(() => <h1>Home</h1>);
179
+
180
+ const user = layout.route("/users/:id").component(function UserPage() {
181
+ const { id } = useParams(user); // Fully typed
182
+ return <h1>User {id}</h1>;
183
+ });
184
+
185
+ // Setup
186
+ const routes = [home, user];
187
+
188
+ function App() {
189
+ return <RouterRoot routes={routes} />;
190
+ }
191
+
192
+ declare module "@typeroute/router" {
193
+ interface Register {
194
+ routes: typeof routes;
195
+ }
196
+ }
197
+ ```
198
+
199
+ Everything autocompletes and type-checks automatically. No heavy setup, no magic, just a simple API that gets out of your way.
200
+
201
+ ๐Ÿ‘‰ [Try it live in the StackBlitz playground](https://stackblitz.com/edit/typeroute-demo?file=src%2Fapp.tsx)
202
+
203
+ ---
204
+
205
+ # Installation
206
+
207
+ ```bash
208
+ npm install @typeroute/router
209
+ ```
210
+
211
+ TypeRoute requires React 18 or higher.
212
+
213
+ ---
214
+
215
+ # Defining routes
216
+
217
+ Routes are created using the `route()` function, following the [builder pattern](https://dev.to/superviz/design-pattern-7-builder-pattern-10j4). You pass it a path and chain methods to configure the route.
218
+
219
+ The `.component()` method tells the route what to render when the path matches. It takes a React component and returns a new route instance with that component attached:
220
+
221
+ ```tsx
222
+ import { route } from "@typeroute/router";
223
+
224
+ const home = route("/").component(HomePage);
225
+ const about = route("/about").component(AboutPage);
226
+ ```
227
+
228
+ Routes support dynamic segments (path params) using the `:param` syntax:
229
+
230
+ ```tsx
231
+ const required = route("/posts/:id");
232
+ const nested = route("/org/:orgId/team/:teamId");
233
+ const optional = route("/book/:title?");
234
+ const suffix = route("/movies/:title.(mp4|mov)");
235
+ ```
236
+
237
+ And wildcard segments that capture everything after a certain point:
238
+
239
+ ```tsx
240
+ const notFound = route("/*").component(NotFoundPage);
241
+ const files = route("/files/*").component(FileBrowser);
242
+ const optional = route("/books/*?").component(FileBrowser);
243
+ ```
244
+
245
+ Route building is immutable: every method on a route returns a new route instance.
246
+
247
+ ---
248
+
249
+ # Nested routes and layouts
250
+
251
+ Any route can have child routes. Call `.route()` on an existing route to create one:
252
+
253
+ ```tsx
254
+ const dashboard = route("/dashboard").component(DashboardLayout);
255
+
256
+ const overview = dashboard.route("/").component(Overview);
257
+ const settings = dashboard.route("/settings").component(Settings);
258
+ const profile = dashboard.route("/profile").component(Profile);
259
+ ```
260
+
261
+ Child routes build on their parent's path. So `overview` matches `/dashboard`, `settings` matches `/dashboard/settings`, and `profile` matches `/dashboard/profile`.
262
+
263
+ They also nest inside the parent's component. The parent renders an `<Outlet />` to mark where child routes should appears:
264
+
265
+ ```tsx
266
+ function DashboardLayout() {
267
+ return (
268
+ <div>
269
+ <Sidebar />
270
+ <main>
271
+ <Outlet />
272
+ </main>
273
+ </div>
274
+ );
275
+ }
276
+ ```
277
+
278
+ When the URL is `/dashboard/settings`, TypeRoute renders `DashboardLayout` with `Settings` inside the outlet. This is how you build layouts - shared UI like navigation or sidebars that stays mounted as users navigate between child routes.
279
+
280
+ You can nest as deep as you need:
281
+
282
+ ```tsx
283
+ const app = route("/").component(AppShell);
284
+ const dashboard = app.route("/dashboard").component(DashboardLayout);
285
+ const settings = dashboard.route("/settings").component(SettingsLayout);
286
+ const security = settings.route("/security").component(SecurityPage);
287
+ ```
288
+
289
+ For the path `/dashboard/settings/security`, this renders:
290
+
291
+ ```
292
+ AppShell
293
+ โ””โ”€โ”€ DashboardLayout
294
+ โ””โ”€โ”€ SettingsLayout
295
+ โ””โ”€โ”€ SecurityPage
296
+ ```
297
+
298
+ Each level must include an `<Outlet />` to render the next level.
299
+
300
+ Beyond paths and components, child routes also inherit search param validators, handles, and preload functions from their parent chain. While you can think of nesting as building a tree, every route is self-contained: it carries everything it needs to render, including all parent components.
301
+
302
+ ---
303
+
304
+ # Setting up the router
305
+
306
+ Before setting up the router, you need to collect your navigable routes into an array. When building nested route hierarchies, you'll often create intermediate parent routes solely for grouping and shared layouts. These intermediate routes shouldn't be included in your routes array - only the final, navigable routes should be:
307
+
308
+ ```tsx
309
+ // Intermediate route used for hierarchy
310
+ const layout = route("/").component(Layout);
311
+
312
+ // Navigable routes that users can actually visit
313
+ const home = layout.route("/").component(Home);
314
+ const about = layout.route("/about").component(About);
315
+
316
+ // Collect only the navigable routes
317
+ const routes = [home, about]; // โœ… Don't include `layout`
318
+ ```
319
+
320
+ This makes sure that only actual pages can be matched and appear in autocomplete. The intermediate routes still exist as part of the hierarchy, they just aren't directly navigable. Note that the order of routes in the array doesn't matter - TypeRoute uses a [ranking algorithm](#route-matching-and-ranking) to pick the most specific match.
321
+
322
+ The `RouterRoot` component is the entry point to TypeRoute. It listens to URL changes, matches the current path against your routes, and renders the matching route's component hierarchy.
323
+
324
+ There are two ways to set it up. The simplest is passing your routes array directly to `RouterRoot`. This creates a router instance internally (accessible via `useRouter`):
325
+
326
+ ```tsx
327
+ import { RouterRoot } from "@typeroute/router";
328
+
329
+ const routes = [home, about];
330
+
331
+ function App() {
332
+ return <RouterRoot routes={routes} />;
333
+ }
334
+ ```
335
+
336
+ You can also pass a `basePath` if your app lives under a subpath:
337
+
338
+ ```tsx
339
+ <RouterRoot routes={routes} basePath="/my-app" />
340
+ ```
341
+
342
+ The second approach is to create a `Router` instance outside of React. This gives you a global router instance that can be accessed from non-React contexts (e.g., utility functions, service modules, or other non-React code):
343
+
344
+ ```tsx
345
+ import { Router, RouterRoot } from "@typeroute/router";
346
+
347
+ const router = new Router({ routes });
348
+
349
+ // Now you can navigate from anywhere
350
+ router.navigate({ to: "/about" });
351
+
352
+ // And pass the instance to RouterRoot
353
+ function App() {
354
+ return <RouterRoot router={router} />;
355
+ }
356
+ ```
357
+
358
+ For full type safety across your app, register your routes using TypeScript's module augmentation. This is a required step for proper autocompletion and type checking:
359
+
360
+ ```tsx
361
+ declare module "@typeroute/router" {
362
+ interface Register {
363
+ routes: typeof routes;
364
+ }
365
+ }
366
+ ```
367
+
368
+ With this in place, `Link`, `navigate`, `useParams`, `useSearch`, and other APIs will know exactly which routes exist and what input they expect.
369
+
370
+ **You're all set up!**
371
+
372
+ ---
373
+
374
+ # Code organization
375
+
376
+ There's no prescribed way to organize your routing code. Since TypeRoute isn't file-based routing, the structure is entirely up to you.
377
+
378
+ That said, here's a pattern that tends to work well: define each route and its component in the same file, then export the route. This keeps everything related to that page in one place:
379
+
380
+ ```tsx
381
+ // pages/home.tsx
382
+ import { route } from "@typeroute/router";
383
+
384
+ export const home = route("/").component(Home);
385
+
386
+ function Home() {
387
+ return <div>Home page</div>;
388
+ }
389
+ ```
390
+
391
+ ```tsx
392
+ // pages/about.tsx
393
+ import { route } from "@typeroute/router";
394
+
395
+ export const about = route("/about").component(About);
396
+
397
+ function About() {
398
+ return <div>About page</div>;
399
+ }
400
+ ```
401
+
402
+ Then in your root app component file, import all the routes, register them with module augmentation, and render `RouterRoot`:
403
+
404
+ ```tsx
405
+ // app.tsx
406
+ import { RouterRoot } from "@typeroute/router";
407
+ import { home } from "./pages/home";
408
+ import { about } from "./pages/about";
409
+
410
+ const routes = [home, about];
411
+
412
+ export function App() {
413
+ return <RouterRoot routes={routes} />;
414
+ }
415
+
416
+ declare module "@typeroute/router" {
417
+ interface Register {
418
+ routes: typeof routes;
419
+ }
420
+ }
421
+ ```
422
+
423
+ But again, this is just one approach. You could keep all routes in a single file, split them by feature, organize them by route depth, whatever fits your project. TypeRoute doesn't care where the routes come from or how you structure your files.
424
+
425
+ ---
426
+
427
+ # Path params
428
+
429
+ Dynamic segments in route patterns become typed path params. Define them with a colon prefix. They can also be made optional.
430
+
431
+ ```tsx
432
+ const post = route("/posts/:id").component(PostPage);
433
+ const comment = route("/posts/:postId/comments/:commentId?").component(
434
+ CommentPage
435
+ );
436
+ ```
437
+
438
+ Access parameters with `useParams`, passing the route pattern or object as an argument:
439
+
440
+ ```tsx
441
+ function PostPage() {
442
+ const { id } = useParams(post);
443
+ // id is typed as string
444
+
445
+ const { id } = useParams("/posts/:id");
446
+ // Also works
447
+ }
448
+
449
+ function CommentPage() {
450
+ const { postId, commentId } = useParams(comment);
451
+ // postId: string
452
+ // commentId?: string | undefined
453
+ }
454
+ ```
455
+
456
+ Wildcard segments capture everything after a slash. They're defined with `*` and accessed with the key `"*"`:
457
+
458
+ ```tsx
459
+ const files = route("/files/*").component(FileBrowser);
460
+
461
+ function FileBrowser() {
462
+ const params = useParams(files);
463
+ const path = params["*"]; // e.g., "documents/report.pdf"
464
+ }
465
+ ```
466
+
467
+ ---
468
+
469
+ # Search params
470
+
471
+ ## Basic usage
472
+
473
+ Search params (the `?key=value` part of URLs) can be typed and validated using the `.search()` method on a route. You can pass either a [Standard Schema](https://standardschema.dev/schema#what-schema-libraries-implement-the-spec) validator like Zod, or a plain validation function.
474
+
475
+ With Zod:
476
+
477
+ ```tsx
478
+ import { z } from "zod";
479
+
480
+ const searchPage = route("/search")
481
+ .search(
482
+ z.object({
483
+ q: z.string().catch(""),
484
+ page: z.coerce.number().catch(1)
485
+ })
486
+ )
487
+ .component(SearchPage);
488
+ ```
489
+
490
+ With a plain function:
491
+
492
+ ```tsx
493
+ const searchPage = route("/search")
494
+ .search(raw => ({
495
+ q: String(raw.q ?? ""),
496
+ page: Number(raw.page ?? 1)
497
+ }))
498
+ .component(SearchPage);
499
+ ```
500
+
501
+ Since you can't control what users put in the URL, your validator should handle missing or malformed values gracefully - validate and normalize rather than reject.
502
+
503
+ Access validated search params with `useSearch`, which returns a tuple of the current values and a setter function:
504
+
505
+ ```tsx
506
+ function SearchPage() {
507
+ const [search, setSearch] = useSearch(searchPage);
508
+ // search.q: string
509
+ // search.page: number
510
+
511
+ const [search, setSearch] = useSearch("/search");
512
+ // Also works
513
+ }
514
+ ```
515
+
516
+ The setter merges your updates with existing values:
517
+
518
+ ```tsx
519
+ setSearch({ page: 2 }); // Only updates page
520
+ setSearch(prev => ({ page: prev.page + 1 })); // Increment page
521
+ ```
522
+
523
+ Pass `true` as the second argument to replace the history entry instead of pushing:
524
+
525
+ ```tsx
526
+ setSearch({ page: 1 }, true);
527
+ ```
528
+
529
+ ## JSON-first approach
530
+
531
+ TypeRoute uses a JSON-first approach for search params, similar to TanStack Router. When serializing and deserializing values from the URL:
532
+
533
+ - Plain strings that aren't valid JSON are kept as-is (and URL-encoded): `"John"` โ†’ `?name=John` โ†’ `"John"`
534
+ - Everything else is JSON-encoded (then URL-encoded):
535
+ - `true` โ†’ `?enabled=true` โ†’ `true`
536
+ - `"true"` โ†’ `?enabled=%22true%22` โ†’ `"true"`
537
+ - `[1, 2]` โ†’ `?filters=%5B1%2C2%5D` โ†’ `[1, 2]`
538
+ - `42` โ†’ `count=42` โ†’ `42`
539
+
540
+ This means you can store complex data structures like arrays and objects in search params without manual serialization. When reading from the URL, TypeRoute automatically parses JSON values back to their original types.
541
+
542
+ The resulting parsed object is what gets passed to the `.search()` function or schema on the route builder. It's typed as `Record<string, unknown>`, which is why validation is useful - it lets you transform these unknown values into a typed, validated shape that your components can safely use.
543
+
544
+ ## Inheritance
545
+
546
+ When you define search params with a validator on a route, all child routes automatically inherit that validator along with its typing. This makes sense because when a child route matches, parent components also render (parents use outlets to display their children), so parent search params remain relevant.
547
+
548
+ Here's how it works. Start with a parent route that defines a search param:
549
+
550
+ ```tsx
551
+ const dashboard = route("/dashboard")
552
+ .search(
553
+ z.object({
554
+ view: z.enum(["grid", "list"]).catch("grid")
555
+ })
556
+ )
557
+ .component(DashboardLayout);
558
+ ```
559
+
560
+ Any child route created from `dashboard` inherits the `view` search param and its validation:
561
+
562
+ ```tsx
563
+ const projects = dashboard.route("/projects").component(ProjectsPage);
564
+
565
+ function ProjectsPage() {
566
+ const [search] = useSearch(projects);
567
+ // search.view is typed as "grid" | "list"
568
+ }
569
+ ```
570
+
571
+ If a child route needs additional search params, define a new validator with `.search()`. Your validator receives the raw params from the URL merged with the parent's already-validated params. After validation, your result is combined with the parent's validated params to produce the final search params object.
572
+
573
+ In practice, this means you only need to validate the new params you're adding - the parent's params are automatically included in the final result:
574
+
575
+ ```tsx
576
+ const projects = dashboard
577
+ .route("/projects")
578
+ .search(
579
+ z.object({
580
+ status: z.enum(["active", "archived"]).catch("active")
581
+ })
582
+ )
583
+ .component(ProjectsPage);
584
+
585
+ function ProjectsPage() {
586
+ const [search] = useSearch(projects);
587
+ // search.view: "grid" | "list" (from parent)
588
+ // search.status: "active" | "archived" (from child)
589
+ }
590
+ ```
591
+
592
+ ## Idempotency requirement
593
+
594
+ The validation function or schema you pass to `.search()` must be **idempotent**, meaning `fn(fn(x))` should equal `fn(x)`.
595
+
596
+ When you read search params, the values are passed through your validator. When you update search params, the navigation APIs expect values in that same validated format, which are then JSON-encoded back into the URL. On the next read, those encoded values are decoded and passed through your validator again - meaning your validator may receive its own output as input.
597
+
598
+ ---
599
+
600
+ # Navigation
601
+
602
+ ## The Link component
603
+
604
+ The `Link` component renders an anchor tag that navigates without a full page reload. It accepts a `to` prop that can be either a route pattern string or a route object:
605
+
606
+ ```tsx
607
+ <Link to="/about">About</Link>
608
+ <Link to={about}>About</Link>
609
+ ```
610
+
611
+ When the route has non-optional path params, you must provide the `params` prop:
612
+
613
+ ```tsx
614
+ <Link to="/posts/:id" params={{ id: postId }}>
615
+ View post
616
+ </Link>
617
+ ```
618
+
619
+ And if the route has search params defined, you can pass them too:
620
+
621
+ ```tsx
622
+ <Link to={userProfile} params={{ id: "42" }} search={{ tab: "posts" }}>
623
+ User posts
624
+ </Link>
625
+ ```
626
+
627
+ To replace the current history entry instead of pushing a new one, use `replace`:
628
+
629
+ ```tsx
630
+ <Link to="/login" replace>
631
+ Login
632
+ </Link>
633
+ ```
634
+
635
+ You can also pass arbitrary state that will be available via `useLocation().state`:
636
+
637
+ ```tsx
638
+ <Link to="/checkout" state={{ from: "cart" }}>
639
+ Checkout
640
+ </Link>
641
+ ```
642
+
643
+ The `asChild` prop lets you use your own component while keeping Link's behavior:
644
+
645
+ ```tsx
646
+ <Link to="/profile" asChild>
647
+ <MyCustomAnchor>Go to profile</MyCustomAnchor>
648
+ </Link>
649
+ ```
650
+
651
+ ## Active state detection
652
+
653
+ Links automatically track whether they match the current URL. When active, they receive a `data-active="true"` attribute and can apply different styles.
654
+
655
+ By default, a link is considered active if the current path starts with the link's target (called "loose matching"). This means a link to `/dashboard` stays active on `/dashboard/settings`. To require an exact match, use the `strict` prop:
656
+
657
+ ```tsx
658
+ <Link to="/dashboard">Active on /dashboard and child routes</Link>
659
+ <Link strict to="/dashboard">Active only on /dashboard</Link>
660
+ ```
661
+
662
+ You can style active links using the data attribute in CSS:
663
+
664
+ ```css
665
+ .nav-link[data-active="true"] {
666
+ font-weight: bold;
667
+ color: blue;
668
+ }
669
+ ```
670
+
671
+ Or use the `activeClassName` and `activeStyle` props directly:
672
+
673
+ ```tsx
674
+ <Link
675
+ to="/dashboard"
676
+ className="nav-link"
677
+ activeClassName="active"
678
+ style={{ opacity: 0.7 }}
679
+ activeStyle={{ opacity: 1 }}
680
+ >
681
+ Dashboard
682
+ </Link>
683
+ ```
684
+
685
+ ## Route preloading
686
+
687
+ Links can optionally trigger route preloading before navigation occurs. When preloading is enabled, any [lazy-loaded components](#lazy-loading) (defined with `.lazy()`) and [preload functions](#data-preloading) (defined with `.preload()`) are called early. This improves perceived performance by loading component bundles and running preparation logic like prefetching data ahead of time.
688
+
689
+ The `preload` prop controls when preloading happens:
690
+
691
+ **`preload="intent"`** preloads when the user shows intent to navigate by hovering or focusing the link. This is the most common choice as it balances eager loading with not wasting bandwidth:
692
+
693
+ ```tsx
694
+ <Link to="/heavy-page" preload="intent">
695
+ Heavy page
696
+ </Link>
697
+ ```
698
+
699
+ **`preload="render"`** preloads as soon as the link mounts. Use this for routes you're confident the user will visit:
700
+
701
+ ```tsx
702
+ <Link to="/next-step" preload="render">
703
+ Next step
704
+ </Link>
705
+ ```
706
+
707
+ **`preload="viewport"`** uses an Intersection Observer to preload when the link scrolls into view. Good for links further down the page and mobile:
708
+
709
+ ```tsx
710
+ <Link to="/section" preload="viewport">
711
+ See more
712
+ </Link>
713
+ ```
714
+
715
+ **`preload={false}`** disables preloading entirely. This is the default.
716
+
717
+ To prevent unwanted preloads from quick hover/focus interactions, Link waits 50ms before triggering. You can customize this with `preloadDelay`:
718
+
719
+ ```tsx
720
+ <Link to="/heavy-page" preload="intent" preloadDelay={100}>
721
+ Heavy page
722
+ </Link>
723
+ ```
724
+
725
+ You can also preload programmatically using `router.preload()`:
726
+
727
+ ```tsx
728
+ const router = useRouter();
729
+ router.preload({ to: userProfile, params: { id: "42" } });
730
+ ```
731
+
732
+ To set a preload strategy globally for all links in your app, see [Global link configuration](#global-link-configuration).
733
+
734
+ ## Programmatic navigation
735
+
736
+ For navigation triggered by code rather than user clicks, use the `useNavigate` hook:
737
+
738
+ ```tsx
739
+ import { useNavigate } from "@typeroute/router";
740
+
741
+ function LoginForm() {
742
+ const navigate = useNavigate();
743
+
744
+ const onSubmit = async () => {
745
+ await login();
746
+ navigate({ to: "/dashboard" });
747
+ };
748
+
749
+ // ...
750
+ }
751
+ ```
752
+
753
+ The navigate function accepts the same navigation options as `Link`:
754
+
755
+ ```tsx
756
+ navigate({ to: userProfile, params: { id: "42" }, search: { tab: "posts" } });
757
+ navigate({ to: "/login", replace: true });
758
+ navigate({ to: "/checkout", state: { from: "cart" } });
759
+ ```
760
+
761
+ To go back or forward in history, pass a number:
762
+
763
+ ```tsx
764
+ navigate(-1); // Go back
765
+ navigate(1); // Go forward
766
+ navigate(-2); // Go back two steps
767
+ ```
768
+
769
+ You can also access the router directly via `useRouter()` (or import the router if created outside of React) and call its `navigate` method, which works the same way:
770
+
771
+ ```tsx
772
+ router.navigate({ to: "/login" });
773
+ ```
774
+
775
+ For unsafe navigation that bypasses type checking, you can pass `url` instead of `to`, `params` and `search`. This is useful when you don't know the target URL statically (e.g., URLs from user input or API responses):
776
+
777
+ ```tsx
778
+ // Type-safe navigation
779
+ navigate({ to: userProfile, params: { id: "42" } });
780
+
781
+ // Unsafe navigation - no type checking
782
+ navigate({ url: "/some/path?tab=settings" });
783
+ navigate({ url: "/callback", replace: true, state: { data: 123 } });
784
+ ```
785
+
786
+ ## Declarative navigation
787
+
788
+ For redirects triggered by rendering rather than events, use the `Navigate` component. It navigates as soon as it mounts, making it useful for conditional redirects based on application state:
789
+
790
+ ```tsx
791
+ import { Navigate } from "@typeroute/router";
792
+
793
+ function ProtectedPage() {
794
+ const { isAuthenticated } = useAuth();
795
+
796
+ if (!isAuthenticated) {
797
+ return <Navigate to="/login" replace />;
798
+ }
799
+
800
+ return <div>Protected content</div>;
801
+ }
802
+ ```
803
+
804
+ The `Navigate` component accepts the same navigation props as the `Link` component:
805
+
806
+ ```tsx
807
+ <Navigate to="/users/:id" params={{ id: "42" }} search={{ tab: "posts" }} />
808
+ <Navigate to="/home" replace />
809
+ <Navigate to={checkout} state={{ from: "cart" }} />
810
+ ```
811
+
812
+ Note that `Navigate` uses `useLayoutEffect` internally to ensure the navigation is triggered before the browser repaints the screen.
813
+
814
+ ---
815
+
816
+ # Lazy loading
817
+
818
+ Load route components on demand with `.lazy()`. The function you pass should return a dynamic import:
819
+
820
+ ```tsx
821
+ const analytics = route("/analytics").lazy(() => import("./AnalyticsPage"));
822
+ ```
823
+
824
+ The imported module should use a default export:
825
+
826
+ ```tsx
827
+ // AnalyticsPage.tsx
828
+ export default function AnalyticsPage() { ... }
829
+ ```
830
+
831
+ If you're using a named export, you need to explicitly select which component to use by chaining `.then()` on the import:
832
+
833
+ ```tsx
834
+ const analytics = route("/analytics").lazy(() =>
835
+ import("./AnalyticsPage").then(m => m.AnalyticsPage)
836
+ );
837
+
838
+ // AnalyticsPage.tsx
839
+ export function AnalyticsPage() { ... }
840
+ ```
841
+
842
+ Lazy routes work like any other route. Child routes inherit the parent's lazy-loaded components:
843
+
844
+ ```tsx
845
+ const dashboard = route("/dashboard").lazy(() => import("./Dashboard"));
846
+ const settings = dashboard.route("/settings").component(Settings);
847
+ ```
848
+
849
+ When navigating to `/dashboard/settings`, React loads the dashboard component first, then renders settings inside it. The Dashboard component must include an `<Outlet />` for the child route to appear.
850
+
851
+ See [Route preloading](#route-preloading) for ways to load these components before the user navigates.
852
+
853
+ ---
854
+
855
+ # Data preloading
856
+
857
+ Use `.preload()` to run logic before navigation occurs, typically to prefetch data. Preload functions receive the target route's typed params and search values:
858
+
859
+ ```tsx
860
+ const userProfile = route("/users/:id")
861
+ .search(z.object({ tab: z.enum(["posts", "comments"]).catch("posts") }))
862
+ .preload(async ({ params, search }) => {
863
+ await queryClient.prefetchQuery({
864
+ queryKey: ["user", params.id, search.tab],
865
+ queryFn: () => fetchUser(params.id, search.tab)
866
+ });
867
+ })
868
+ .component(UserProfile);
869
+ ```
870
+
871
+ See [Route preloading](#route-preloading) for how to trigger preload functions.
872
+
873
+ Depending on when and how preloading is triggered, these functions may run repeatedly. TypeRoute intentionally doesn't cache or deduplicate the calls - that's the job of your data layer. Libraries like TanStack Query, SWR, or Apollo handle this well. For example, TanStack Query's `staleTime` prevents refetches when data is still fresh:
874
+
875
+ ```tsx
876
+ await queryClient.prefetchQuery({
877
+ queryKey: ["user", params.id],
878
+ queryFn: () => fetchUser(params.id),
879
+ staleTime: 60_000 // No refetch within 60s
880
+ });
881
+ ```
882
+
883
+ Preload functions inherit to child routes:
884
+
885
+ ```tsx
886
+ const dashboard = route("/dashboard")
887
+ .preload(prefetchDashboardData)
888
+ .component(DashboardLayout);
889
+
890
+ const settings = dashboard.route("/settings").component(Settings);
891
+ // Preloading /dashboard/settings runs prefetchDashboardData
892
+ ```
893
+
894
+ ---
895
+
896
+ # Error boundaries
897
+
898
+ Catch errors thrown during rendering with `.error()`. The error component receives the error as a prop:
899
+
900
+ ```tsx
901
+ const fragile = route("/fragile").error(ErrorFallback).component(FragilePage);
902
+
903
+ function ErrorFallback({ error }: { error: unknown }) {
904
+ return (
905
+ <div>
906
+ <h2>Something went wrong</h2>
907
+ <pre>{String(error)}</pre>
908
+ <button onClick={() => window.location.reload()}>Retry</button>
909
+ </div>
910
+ );
911
+ }
912
+ ```
913
+
914
+ Error boundaries catch errors from all nested content. A common pattern is to place one at the root to catch any unhandled errors:
915
+
916
+ ```tsx
917
+ const app = route("/").error(ErrorPage).component(AppLayout);
918
+ ```
919
+
920
+ To give new routes a fresh start, the error boundary automatically resets when navigation occurs.
921
+
922
+ ---
923
+
924
+ # Suspense boundaries
925
+
926
+ When using lazy loading or React's `use()` hook for data fetching, you may want to add suspense boundaries to show loading states. Add them with `.suspense()`:
927
+
928
+ ```tsx
929
+ const dataPage = route("/data")
930
+ .suspense(LoadingPage)
931
+ .lazy(() => import("./DataPage"));
932
+
933
+ function LoadingPage() {
934
+ return <div>Loading...</div>;
935
+ }
936
+ ```
937
+
938
+ The suspense boundary wraps everything below it in the route tree. Place it strategically to control which parts of the UI show a loading state.
939
+
940
+ You can combine suspense with error boundaries:
941
+
942
+ ```tsx
943
+ const riskyPage = route("/risky")
944
+ .error(ErrorFallback)
945
+ .suspense(Loading)
946
+ .lazy(() => import("./RiskyPage"));
947
+ ```
948
+
949
+ Note: React 19 has a [known throttling behavior](https://github.com/facebook/react/issues/31819) where suspense fallback hiding is delayed by up to 300ms. This can make fast-loading content feel slower than it is. Keep this in mind when designing loading experiences.
950
+
951
+ ---
952
+
953
+ # Route handles
954
+
955
+ Handles let you attach static arbitrary metadata to routes. This is useful for breadcrumbs, page titles, access control flags, or any other static data you want to associate with a route.
956
+
957
+ Define handles with `.handle()`:
958
+
959
+ ```tsx
960
+ const dashboard = route("/dashboard")
961
+ .handle({ title: "Dashboard", requiresAuth: true })
962
+ .component(DashboardPage);
963
+
964
+ const settings = dashboard
965
+ .route("/settings")
966
+ .handle({ title: "Settings" })
967
+ .component(SettingsPage);
968
+ ```
969
+
970
+ Access all handles from the current route chain with `useHandles()`. It returns an array of all handles from the root down to the current matching route. This hook can be called from anywhere inside the route tree:
971
+
972
+ ```tsx
973
+ function Breadcrumbs() {
974
+ const handles = useHandles();
975
+ return (
976
+ <nav>
977
+ {handles.map((h, i) => (
978
+ <span key={i}>
979
+ {i !== 0 && " / "}
980
+ {h.title}
981
+ </span>
982
+ ))}
983
+ </nav>
984
+ );
985
+ }
986
+ ```
987
+
988
+ On `/dashboard/settings`, this renders "Dashboard / Settings". You can place the `Breadcrumbs` component anywhere in your app layout, and it will always reflect the current route's handle chain.
989
+
990
+ For type safety, register your handle type in the module augmentation:
991
+
992
+ ```tsx
993
+ declare module "@typeroute/router" {
994
+ interface Register {
995
+ routes: typeof routes;
996
+ handle: { title: string; requiresAuth?: boolean };
997
+ }
998
+ }
999
+ ```
1000
+
1001
+ ---
1002
+
1003
+ # Middlewares
1004
+
1005
+ Middlewares bundle reusable configuration that can be applied to multiple routes. Instead of repeating the same configuration across routes, you define it once in a middleware and apply it wherever needed.
1006
+
1007
+ Create middleware with the `middleware()` function. It returns a middleware object that supports the same builder methods as routes, except `.route()`.
1008
+
1009
+ ```tsx
1010
+ import { middleware } from "@typeroute/router";
1011
+
1012
+ const pagination = middleware().search(
1013
+ z.object({
1014
+ page: z.coerce.number().catch(1),
1015
+ limit: z.coerce.number().catch(10)
1016
+ })
1017
+ );
1018
+
1019
+ const auth = middleware()
1020
+ .handle({ requiresAuth: true })
1021
+ .component(AuthRedirect);
1022
+ ```
1023
+
1024
+ Here, `pagination` validates pagination search params, and `auth` marks routes as protected via a handle and wraps them in a component that redirects unauthenticated users.
1025
+
1026
+ Apply middleware to a route with the `.use()` method:
1027
+
1028
+ ```tsx
1029
+ const userPage = route("/users").use(pagination).component(UserPage);
1030
+
1031
+ function UserPage() {
1032
+ const [search] = useSearch(userPage);
1033
+ // search.page: number
1034
+ // search.limit: number
1035
+ }
1036
+ ```
1037
+
1038
+ The middleware's configuration merges into the route - here, the route gets typed and validated `page` and `limit` search params. You can apply multiple middlewares to the same route:
1039
+
1040
+ ```tsx
1041
+ route("/users").use(auth).use(pagination).component(UserPage);
1042
+ ```
1043
+
1044
+ Middlewares can also use other middlewares:
1045
+
1046
+ ```tsx
1047
+ const filter = middleware()
1048
+ .use(pagination)
1049
+ .search(
1050
+ z.object({
1051
+ status: z.enum(["active", "archived", "all"]).catch("all")
1052
+ })
1053
+ );
1054
+ ```
1055
+
1056
+ Any route using `filter` gets pagination and filtering by status combined:
1057
+
1058
+ ```tsx
1059
+ const userPage = route("/users").use(filter).component(UserPage);
1060
+
1061
+ function UserPage() {
1062
+ const [search] = useSearch(userPage);
1063
+ // search.page: number
1064
+ // search.limit: number
1065
+ // search.status: "active" | "archived" | "all"
1066
+ }
1067
+ ```
1068
+
1069
+ For parametrized middlewares, define a function that returns a middleware:
1070
+
1071
+ ```tsx
1072
+ const guard = (role: string) =>
1073
+ middleware().handle({ requiredRole: role }).component(RoleGuard);
1074
+
1075
+ const adminPage = route("/admin").use(guard("admin")).component(AdminPage);
1076
+ const editorPage = route("/editor").use(guard("editor")).component(EditorPage);
1077
+ ```
1078
+
1079
+ ---
1080
+
1081
+ # Route matching and ranking
1082
+
1083
+ When a user navigates to a URL, TypeRoute needs to determine which route matches. Since multiple routes can potentially match the same path (think `/users/:id` vs `/users/new`), TypeRoute uses a ranking algorithm to pick the most specific one.
1084
+
1085
+ Each segment in a route pattern gets a weight:
1086
+
1087
+ | Segment type | Weight | Example |
1088
+ | ------------ | ------ | -------------------------- |
1089
+ | Static | 2 | `users`, `settings`, `new` |
1090
+ | Dynamic | 1 | `:id`, `:slug?` |
1091
+ | Wildcard | 0 | `*`, `*?` |
1092
+
1093
+ When multiple routes match, TypeRoute compares them segment by segment from left to right. The route with the higher weight at the first differing position wins. If weights are equal, it continues to the next segment.
1094
+
1095
+ Consider these routes:
1096
+
1097
+ ```tsx
1098
+ const userNew = route("/users/new").component(NewUser);
1099
+ const userProfile = route("/users/:id").component(UserProfile);
1100
+ const userCatchAll = route("/users/*").component(UserCatchAll);
1101
+ ```
1102
+
1103
+ For the path `/users/new`, all three would match. TypeRoute ranks them to pick the most specific:
1104
+
1105
+ ```
1106
+ /users/new โ†’ [static, static] โ†’ weights [2, 2] โœ“ Wins
1107
+ /users/:id โ†’ [static, dynamic] โ†’ weights [2, 1]
1108
+ /users/* โ†’ [static, wildcard] โ†’ weights [2, 0]
1109
+ ```
1110
+
1111
+ The first segment (`users`) is static in all routes, so they all score 2 there. The second segment differs: `new` is static (2), `:id` is dynamic (1), and `*` is a wildcard (0). So `/users/new` wins.
1112
+
1113
+ For the path `/users/42`:
1114
+
1115
+ ```
1116
+ /users/new โ†’ doesn't match
1117
+ /users/:id โ†’ [static, dynamic] โ†’ weights [2, 1] โœ“ Wins
1118
+ /users/* โ†’ [static, wildcard] โ†’ weights [2, 0]
1119
+ ```
1120
+
1121
+ This ranking algorithm means you don't need to order your routes array carefully. Define them in any order and TypeRoute figures out the right match regardless:
1122
+
1123
+ ```tsx
1124
+ const routes = [
1125
+ route("/posts/*").component(NotFound),
1126
+ route("/posts/:id").component(PostPage),
1127
+ route("/posts/new").component(NewPost)
1128
+ ]; // Order doesn't matter
1129
+ ```
1130
+
1131
+ ---
1132
+
1133
+ # History implementations
1134
+
1135
+ History is an abstraction layer that sits between the router and the actual low-level navigation logic. It handles reading and updating the current location, managing navigation state, and notifying when the URL changes. This abstraction allows TypeRoute to work in different environments (browser, hash-based, in-memory, server-side, tests, etc.) without changing the router's core logic. You can switch between environments simply by swapping the history implementation - the rest of your app stays exactly the same.
1136
+
1137
+ TypeRoute supports three history modes out of the box.
1138
+
1139
+ **BrowserHistory** is the default. It uses the browser's History API, working with browser URLs like `/posts/123`:
1140
+
1141
+ ```tsx
1142
+ import { BrowserHistory } from "@typeroute/router";
1143
+
1144
+ <RouterRoot routes={routes} history={new BrowserHistory()} />;
1145
+ ```
1146
+
1147
+ **HashHistory** stores the path in the URL hash, producing URLs like `/#/posts/123`. This is useful for static file hosting where you can't configure server-side routing:
1148
+
1149
+ ```tsx
1150
+ import { HashHistory } from "@typeroute/router";
1151
+
1152
+ <RouterRoot routes={routes} history={new HashHistory()} />;
1153
+ ```
1154
+
1155
+ **MemoryHistory** keeps the history in memory without touching the URL. It also doesn't rely on any browser API. Perfect for testing, server-side rendering (SSR), or embedded applications:
1156
+
1157
+ ```tsx
1158
+ import { MemoryHistory } from "@typeroute/router";
1159
+
1160
+ <RouterRoot routes={routes} history={new MemoryHistory("/initial/path")} />;
1161
+ ```
1162
+
1163
+ All history implementations conform to the `HistoryLike` interface, so you can create custom implementations if needed.
1164
+
1165
+ ---
1166
+
1167
+ # Devtools
1168
+
1169
+ TypeRoute has a companion devtools package for inspecting routes, matches, parameters, and navigation state.
1170
+
1171
+ ```bash
1172
+ npm install @typeroute/devtools
1173
+ ```
1174
+
1175
+ Render the `Devtools` component anywhere inside your routes. It displays a toggle button that opens a draggable and resizable floating panel:
1176
+
1177
+ ```tsx
1178
+ import { Devtools } from "@typeroute/devtools";
1179
+
1180
+ const layout = route("/").component(Layout);
1181
+
1182
+ function Layout() {
1183
+ return (
1184
+ <div>
1185
+ <Outlet />
1186
+ <Devtools />
1187
+ </div>
1188
+ );
1189
+ }
1190
+ ```
1191
+
1192
+ If you'd rather embed the panel directly into your layout instead of using the floating window, use `DevtoolsPanel`:
1193
+
1194
+ ```tsx
1195
+ import { DevtoolsPanel } from "@typeroute/devtools";
1196
+
1197
+ function DebugSidebar() {
1198
+ return (
1199
+ <aside>
1200
+ <DevtoolsPanel />
1201
+ </aside>
1202
+ );
1203
+ }
1204
+ ```
1205
+
1206
+ To exclude devtools from production builds (Vite example):
1207
+
1208
+ ```tsx
1209
+ import.meta.env.DEV && <Devtools />;
1210
+ ```
1211
+
1212
+ ---
1213
+
1214
+ # Cookbook
1215
+
1216
+ ## Quick start example
1217
+
1218
+ Here's a minimal but complete routing setup with a layout and two pages:
1219
+
1220
+ ```tsx
1221
+ import { route, RouterRoot, Outlet, Link } from "@typeroute/router";
1222
+
1223
+ // Layout route
1224
+ const app = route("/").component(AppLayout);
1225
+
1226
+ function AppLayout() {
1227
+ return (
1228
+ <div>
1229
+ <nav>
1230
+ <Link to="/">Home</Link>
1231
+ <Link to="/about">About</Link>
1232
+ </nav>
1233
+ <main>
1234
+ <Outlet />
1235
+ </main>
1236
+ </div>
1237
+ );
1238
+ }
1239
+
1240
+ // Page routes
1241
+ const home = app.route("/").component(() => <h1>Welcome home</h1>);
1242
+ const about = app.route("/about").component(() => <h1>About us</h1>);
1243
+
1244
+ // Router setup
1245
+ const routes = [home, about];
1246
+
1247
+ export function App() {
1248
+ return <RouterRoot routes={routes} />;
1249
+ }
1250
+
1251
+ declare module "@typeroute/router" {
1252
+ interface Register {
1253
+ routes: typeof routes;
1254
+ }
1255
+ }
1256
+ ```
1257
+
1258
+ ## Server-side rendering (SSR)
1259
+
1260
+ TypeRoute supports server-side rendering using `MemoryHistory`. The key is to use `MemoryHistory` on the server (initialized with the request URL) and `BrowserHistory` on the client:
1261
+
1262
+ ```tsx
1263
+ // server.tsx
1264
+ import { renderToString } from "react-dom/server";
1265
+ import { RouterRoot, MemoryHistory, type SSRContext } from "@typeroute/router";
1266
+ import { routes } from "./routes";
1267
+
1268
+ function handleRequest(req: Request) {
1269
+ const ssrContext: SSRContext = {};
1270
+ const html = renderToString(
1271
+ <RouterRoot
1272
+ routes={routes}
1273
+ history={new MemoryHistory(req.url)}
1274
+ ssrContext={ssrContext}
1275
+ />
1276
+ );
1277
+ if (ssrContext.redirect) {
1278
+ return Response.redirect(ssrContext.redirect);
1279
+ }
1280
+ return new Response(html, {
1281
+ headers: { "Content-Type": "text/html" }
1282
+ });
1283
+ }
1284
+ ```
1285
+
1286
+ The `ssrContext` object captures information during server rendering. When a `Navigate` component renders on the server (typically from conditional logic), it populates `ssrContext.redirect` with the target URL. Your server can then return an HTTP redirect instead of the rendered HTML.
1287
+
1288
+ On the client, use the default (`BrowserHistory`) for hydration:
1289
+
1290
+ ```tsx
1291
+ // client.tsx
1292
+ import { hydrateRoot } from "react-dom/client";
1293
+ import { RouterRoot } from "@typeroute/router";
1294
+ import { routes } from "./routes";
1295
+
1296
+ hydrateRoot(rootElement, <RouterRoot routes={routes} />);
1297
+ ```
1298
+
1299
+ You can also manually set `ssrContext.statusCode` in your components during SSR to control the response status (like 404 for not found pages).
1300
+
1301
+ ## Scroll to top on navigation
1302
+
1303
+ Create a component that scrolls to top when the path changes and include it in your layout:
1304
+
1305
+ ```tsx
1306
+ import { useLocation } from "@typeroute/router";
1307
+ import { useEffect } from "react";
1308
+
1309
+ function ScrollToTop() {
1310
+ const { path } = useLocation();
1311
+ useEffect(() => window.scrollTo(0, 0), [path]);
1312
+ return null;
1313
+ }
1314
+
1315
+ function AppLayout() {
1316
+ return (
1317
+ <>
1318
+ <ScrollToTop />
1319
+ <Header />
1320
+ <Outlet />
1321
+ </>
1322
+ );
1323
+ }
1324
+ ```
1325
+
1326
+ ## Matching a route anywhere
1327
+
1328
+ Use `useMatch` to check if a route matches the current path from anywhere in your component tree. You can pass either a route pattern string or a route object, just like with `Link` and `navigate`. This is useful for conditional rendering, styling, access control, and more. It's also used internally by `useParams` and `Link`.
1329
+
1330
+ The hook returns a Match object (containing `route` and `params`) if there's a match, or `null` otherwise. There are two matching modes:
1331
+
1332
+ - **Loose matching** (default): Matches if the path starts with the route pattern (e.g., `/dashboard` matches `/dashboard/settings`).
1333
+ - **Strict matching** (`strict: true`): Matches only if the path exactly matches the route pattern.
1334
+
1335
+ ```tsx
1336
+ import { useMatch } from "@typeroute/router";
1337
+
1338
+ const dashboard = route("/dashboard").component(Dashboard);
1339
+
1340
+ function Sidebar() {
1341
+ // Matches /dashboard, /dashboard/anything, etc.
1342
+ const match = useMatch({ from: dashboard });
1343
+
1344
+ // Matches only /dashboard
1345
+ const match = useMatch({ from: dashboard, strict: true });
1346
+
1347
+ return <nav>{match && <DashboardMenu />}</nav>;
1348
+ }
1349
+ ```
1350
+
1351
+ You can also filter by param values to match only specific instances:
1352
+
1353
+ ```tsx
1354
+ const adminMatch = useMatch({
1355
+ from: "/users/:id",
1356
+ params: { id: "admin" }
1357
+ });
1358
+
1359
+ if (adminMatch) {
1360
+ // Currently viewing the admin user
1361
+ }
1362
+ ```
1363
+
1364
+ ## Global link configuration
1365
+
1366
+ Set defaults for all `Link` components using `defaultLinkOptions` on the router. Useful for consistent styling and preload behavior across your app:
1367
+
1368
+ ```tsx
1369
+ <RouterRoot
1370
+ routes={routes}
1371
+ defaultLinkOptions={{
1372
+ preload: "intent",
1373
+ preloadDelay: 75,
1374
+ className: "app-link",
1375
+ activeClassName: "active"
1376
+ }}
1377
+ />
1378
+ ```
1379
+
1380
+ Individual links can override any of these defaults by passing their own props.
1381
+
1382
+ ## History middleware
1383
+
1384
+ This is a design pattern rather than a feature. You can extend history behavior for logging, analytics, or other side effects by monkey-patching the history instance:
1385
+
1386
+ ```tsx
1387
+ function withAnalytics(history: HistoryLike): HistoryLike {
1388
+ const { push } = history;
1389
+
1390
+ history.push = options => {
1391
+ analytics.track("page_view", { url: options.url });
1392
+ push(options);
1393
+ };
1394
+
1395
+ return history;
1396
+ }
1397
+
1398
+ function withLogging(history: HistoryLike): HistoryLike {
1399
+ const { go, push } = history;
1400
+
1401
+ history.go = delta => {
1402
+ console.log("Navigate", delta > 0 ? "forward" : "back");
1403
+ go(delta);
1404
+ };
1405
+
1406
+ history.push = options => {
1407
+ console.log("Navigate to", options.url);
1408
+ push(options);
1409
+ };
1410
+
1411
+ return history;
1412
+ }
1413
+
1414
+ // Compose middlewares
1415
+ const router = new Router({
1416
+ routes,
1417
+ history: withLogging(withAnalytics(new BrowserHistory()))
1418
+ });
1419
+ ```
1420
+
1421
+ ## View transitions
1422
+
1423
+ You can use the view transitions API for smoother page animations. Create a history middleware that wraps navigation in a view transition:
1424
+
1425
+ ```tsx
1426
+ import { flushSync } from "react-dom";
1427
+ import { BrowserHistory, type HistoryLike } from "@typeroute/router";
1428
+
1429
+ const withViewTransition = (history: HistoryLike) => {
1430
+ const { go, push } = history;
1431
+
1432
+ const wrap = (fn: () => void) => {
1433
+ return !document.startViewTransition
1434
+ ? fn()
1435
+ : document.startViewTransition(() => flushSync(fn));
1436
+ };
1437
+
1438
+ history.go = delta => wrap(() => go(delta));
1439
+ history.push = options => wrap(() => push(options));
1440
+ return history;
1441
+ };
1442
+
1443
+ const history = withViewTransition(new BrowserHistory());
1444
+
1445
+ function App() {
1446
+ return <RouterRoot routes={routes} history={history} />;
1447
+ }
1448
+ ```
1449
+
1450
+ Add CSS to control the transition:
1451
+
1452
+ ```css
1453
+ ::view-transition-old(root),
1454
+ ::view-transition-new(root) {
1455
+ animation-duration: 200ms;
1456
+ }
1457
+ ```
1458
+
1459
+ For more advanced techniques, see the [MDN documentation on View Transitions](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API).
1460
+
1461
+ ---
1462
+
1463
+ # API reference
1464
+
1465
+ ## Router class
1466
+
1467
+ The `Router` class is the core of TypeRoute. You can create an instance directly or let `RouterRoot` create one.
1468
+
1469
+ **Properties:**
1470
+
1471
+ - `router.basePath` - The configured base path
1472
+ - `router.routes` - The array of routes
1473
+ - `router.history` - The history instance
1474
+ - `router.ssrContext` - The SSR context (if provided)
1475
+ - `router.defaultLinkOptions` - Default link options
1476
+
1477
+ **`new Router(options)`** creates a new router.
1478
+
1479
+ - `options` - `RouterOptions` - Router configuration
1480
+ - Returns: `Router` - A new router instance
1481
+
1482
+ ```tsx
1483
+ const router = new Router({ routes });
1484
+ const router = new Router({ routes, basePath: "/app" });
1485
+ const router = new Router({ routes, history: new HashHistory() });
1486
+ ```
1487
+
1488
+ **`router.navigate(options)`** navigates to a new location.
1489
+
1490
+ - `options` - `NavigateOptions | HistoryPushOptions | number` - Type-safe navigation options, untyped navigation options, or a history delta
1491
+ - Returns: `void`
1492
+
1493
+ ```tsx
1494
+ // Type-safe navigation
1495
+ router.navigate({ to: "/posts/:id", params: { id: "42" } });
1496
+
1497
+ // Untyped navigation
1498
+ router.navigate({ url: "/any/path" });
1499
+
1500
+ // History navigation
1501
+ router.navigate(-1); // Back
1502
+ router.navigate(1); // Forward
1503
+ ```
1504
+
1505
+ **`router.createUrl(options)`** builds a URL string.
1506
+
1507
+ - `options` - `NavigateOptions` - Type-safe navigation options
1508
+ - Returns: `string` - The constructed URL
1509
+
1510
+ ```tsx
1511
+ const url = router.createUrl({ to: userProfile, params: { id: "42" } });
1512
+ // Returns "/users/42"
1513
+ ```
1514
+
1515
+ **`router.match(path, options)`** checks if a path matches a specific route.
1516
+
1517
+ - `path` - `string` - The path to match against
1518
+ - `options` - `MatchOptions` - Matching options
1519
+ - Returns: `Match | null` - The match result or null if no match
1520
+
1521
+ ```tsx
1522
+ const match = router.match("/users/42", { from: "/users/:id" });
1523
+ // Returns { route, params: { id: "42" } }
1524
+ ```
1525
+
1526
+ **`router.matchAll(path)`** finds the best match from all registered routes.
1527
+
1528
+ - `path` - `string` - The path to match against
1529
+ - Returns: `Match | null` - The best match or null if no route matches
1530
+
1531
+ ```tsx
1532
+ const match = router.matchAll("/users/42");
1533
+ // Returns the best match or null
1534
+ ```
1535
+
1536
+ **`router.getRoute(pattern)`** get a route by its pattern.
1537
+
1538
+ - `pattern` - `Pattern | Route` - A route pattern string or a route object
1539
+ - Returns: `Route` - The route object; throws if not found
1540
+
1541
+ ```tsx
1542
+ const route = router.getRoute("/users/:id");
1543
+ ```
1544
+
1545
+ **`router.preload(options)`** triggers preloading for a route.
1546
+
1547
+ - `options` - `NavigateOptions` - Type-safe navigation options
1548
+ - Returns: `Promise<void>` - Resolves when preloaded
1549
+
1550
+ ```tsx
1551
+ await router.preload({ to: "/user/:id", params: { id: "42" } });
1552
+ await router.preload({ to: searchPage, search: { q: "test" } });
1553
+ ```
1554
+
1555
+ ## Route class
1556
+
1557
+ Routes are created with the `route()` function and configured by chaining methods.
1558
+
1559
+ **`route(pattern)`** creates a new route.
1560
+
1561
+ - `pattern` - `string` - The route path pattern (e.g., `"/users"`, `"/users/:id"`, `"/*"`)
1562
+ - Returns: `Route` - A new route object
1563
+
1564
+ ```tsx
1565
+ const users = route("/users");
1566
+ const user = route("/users/:id");
1567
+ const catchAll = route("/*");
1568
+ ```
1569
+
1570
+ **`.route(pattern)`** creates a nested child route.
1571
+
1572
+ - `pattern` - `string` - The child path pattern to append
1573
+ - Returns: `Route` - A new route object
1574
+
1575
+ ```tsx
1576
+ const userSettings = user.route("/settings");
1577
+ // Pattern becomes "/users/:id/settings"
1578
+ ```
1579
+
1580
+ **`.use(middleware)`** applies a middleware to the route, merging its configuration.
1581
+
1582
+ - `middleware` - `Middleware` - A middleware object
1583
+ - Returns: `Route` - A new route object
1584
+
1585
+ ```tsx
1586
+ const auth = middleware().component(AuthRedirect);
1587
+ const dashboard = route("/dashboard").use(auth).component(Dashboard);
1588
+ ```
1589
+
1590
+ **`.component(component)`** adds a component to render when this route matches.
1591
+
1592
+ - `component` - `ComponentType` - A React component
1593
+ - Returns: `Route` - A new route object
1594
+
1595
+ ```tsx
1596
+ const users = route("/users").component(UsersPage);
1597
+ ```
1598
+
1599
+ **`.lazy(loader)`** adds a lazy-loaded component to render when this route matches.
1600
+
1601
+ - `loader` - `ComponentLoader` - A function returning a dynamic import promise
1602
+ - Returns: `Route` - A new route object
1603
+
1604
+ ```tsx
1605
+ const users = route("/users").lazy(() => import("./UsersPage"));
1606
+ const admin = route("/admin").lazy(() =>
1607
+ import("./Admin").then(m => m.AdminPage)
1608
+ );
1609
+ ```
1610
+
1611
+ **`.search(validate)`** adds search parameter validation.
1612
+
1613
+ - `validate` - `StandardSchema | ((search) => ValidatedSearch)` - A Standard Schema (like Zod) or a validation function
1614
+ - Returns: `Route` - A new route object
1615
+
1616
+ ```tsx
1617
+ const search = route("/search").search(z.object({ q: z.string() }));
1618
+ const filter = route("/filter").search(raw => ({
1619
+ term: String(raw.term ?? "")
1620
+ }));
1621
+ ```
1622
+
1623
+ **`.handle(handle)`** attaches static metadata to the route.
1624
+
1625
+ - `handle` - `Handle` - Arbitrary metadata
1626
+ - Returns: `Route` - A new route object
1627
+
1628
+ ```tsx
1629
+ const admin = route("/admin").handle({ requiresAuth: true });
1630
+ ```
1631
+
1632
+ **`.suspense(fallback)`** wraps nested content in a Suspense boundary.
1633
+
1634
+ - `fallback` - `ComponentType` - The fallback component to show while suspended
1635
+ - Returns: `Route` - A new route object
1636
+
1637
+ ```tsx
1638
+ const lazy = route("/lazy")
1639
+ .suspense(Loading)
1640
+ .lazy(() => import("./Page"));
1641
+ ```
1642
+
1643
+ **`.error(fallback)`** wraps nested content in an error boundary.
1644
+
1645
+ - `fallback` - `ComponentType<{ error: unknown }>` - The fallback component, receives the caught error as a prop
1646
+ - Returns: `Route` - A new route object
1647
+
1648
+ ```tsx
1649
+ const risky = route("/risky").error(ErrorPage).component(RiskyPage);
1650
+ ```
1651
+
1652
+ **`.preload(preload)`** registers a preload function for the route.
1653
+
1654
+ - `preload` - `(context: PreloadContext) => Promise<any>` - An async function receiving typed `params` and `search`
1655
+ - Returns: `Route` - A new route object
1656
+
1657
+ ```tsx
1658
+ const user = route("/users/:id")
1659
+ .search(z.object({ tab: z.string().catch("profile") }))
1660
+ .preload(async ({ params, search }) => {
1661
+ // params.id: string, search.tab: string - fully typed
1662
+ await prefetchUser(params.id, search.tab);
1663
+ });
1664
+ ```
1665
+
1666
+ ## Middleware
1667
+
1668
+ **`middleware()`** creates a new middleware.
1669
+
1670
+ - Returns: `Middleware` - A new middleware object
1671
+
1672
+ ```tsx
1673
+ const pagination = middleware().search(
1674
+ z.object({
1675
+ page: z.coerce.number().catch(1),
1676
+ limit: z.coerce.number().catch(10)
1677
+ })
1678
+ );
1679
+ const auth = middleware()
1680
+ .handle({ requiresAuth: true })
1681
+ .component(AuthRedirect);
1682
+ ```
1683
+
1684
+ Middlewares support all the same builder methods as `Route` except `.route()`. See the [Route class](#route-class) documentation above for details on each method.
1685
+
1686
+ ## Hooks
1687
+
1688
+ **`useRouter()`** returns the Router instance from context.
1689
+
1690
+ - Returns: `Router` - The router instance
1691
+
1692
+ ```tsx
1693
+ const router = useRouter();
1694
+ ```
1695
+
1696
+ **`useNavigate()`** returns a navigation function.
1697
+
1698
+ - Returns: `(options: NavigateOptions | HistoryPushOptions | number) => void` - The navigate function
1699
+
1700
+ ```tsx
1701
+ const navigate = useNavigate();
1702
+ navigate({ to: "/home" });
1703
+ navigate(-1);
1704
+ ```
1705
+
1706
+ **`useLocation()`** returns the current location, subscribes to changes.
1707
+
1708
+ - Returns: `HistoryLocation` - The current location with path, parsed search params, and history state
1709
+
1710
+ ```tsx
1711
+ const { path, search, state } = useLocation();
1712
+ ```
1713
+
1714
+ **`useOutlet()`** returns the child route content.
1715
+
1716
+ - Returns: `ReactNode` - The child route's content or null
1717
+
1718
+ ```tsx
1719
+ const outlet = useOutlet();
1720
+ ```
1721
+
1722
+ **`useParams(route)`** returns typed path params for a route.
1723
+
1724
+ - `route` - `Pattern | Route` - A route pattern string or route object
1725
+ - Returns: `Params` - The extracted path params, fully typed
1726
+
1727
+ ```tsx
1728
+ const { id } = useParams(userRoute);
1729
+ ```
1730
+
1731
+ **`useSearch(route)`** returns validated search params and a setter function.
1732
+
1733
+ - `route` - `Pattern | Route` - A route pattern string or route object
1734
+ - Returns: `[Search, SetSearch]` - A tuple of the validated search params and a setter; the setter accepts a partial update or an updater function, with an optional second argument to replace instead of push
1735
+
1736
+ ```tsx
1737
+ const [search, setSearch] = useSearch(searchRoute);
1738
+ setSearch({ page: 2 });
1739
+ setSearch(prev => ({ page: prev.page + 1 }));
1740
+ setSearch({ page: 1 }, true); // Replace instead of push
1741
+ ```
1742
+
1743
+ **`useMatch(options)`** checks if a route matches the current path.
1744
+
1745
+ - `options` - `MatchOptions` - Matching options
1746
+ - Returns: `Match | null` - The match result or null if no match
1747
+
1748
+ ```tsx
1749
+ const match = useMatch({ from: "/users/:id" });
1750
+ const strictMatch = useMatch({ from: "/users", strict: true });
1751
+ const filteredMatch = useMatch({ from: "/users/:id", params: { id: "admin" } });
1752
+ ```
1753
+
1754
+ **`useHandles()`** returns the handles from the matched route chain.
1755
+
1756
+ - Returns: `Handle[]` - Array of handles
1757
+
1758
+ ```tsx
1759
+ const handles = useHandles();
1760
+ ```
1761
+
1762
+ ## Components
1763
+
1764
+ **`RouterRoot`** sets up routing context and renders your routes.
1765
+
1766
+ - `props` - `RouterOptions | { router: Router }` - Either router options (same as the `Router` constructor) or a router instance
1767
+
1768
+ ```tsx
1769
+ <RouterRoot routes={routes} basePath="/app" history={history} />
1770
+ <RouterRoot router={router} />
1771
+ ```
1772
+
1773
+ **`Outlet`** renders the child route content.
1774
+
1775
+ ```tsx
1776
+ function Layout() {
1777
+ return (
1778
+ <div>
1779
+ <Outlet />
1780
+ </div>
1781
+ );
1782
+ }
1783
+ ```
1784
+
1785
+ **`Link`** renders an anchor tag for navigation.
1786
+
1787
+ - `props` - `NavigateOptions & LinkOptions & { asChild?: boolean }` - Navigation options, link options, and optional `asChild` to use a child element as the anchor; other props are passed through
1788
+
1789
+ ```tsx
1790
+ <Link to="/path" params={...} search={...} replace strict preload="intent">
1791
+ Click me
1792
+ </Link>
1793
+ ```
1794
+
1795
+ **`Navigate`** redirects on render.
1796
+
1797
+ - `props` - `NavigateOptions` - The navigation target
1798
+
1799
+ ```tsx
1800
+ <Navigate to="/login" replace />
1801
+ ```
1802
+
1803
+ ## History interface
1804
+
1805
+ The `HistoryLike` interface defines how TypeRoute interacts with navigation. All history implementations conform to this interface.
1806
+
1807
+ **Available implementations:**
1808
+
1809
+ ```tsx
1810
+ new BrowserHistory(); // Browser History API (/posts/123). Default.
1811
+ new HashHistory(); // URL hash (/#/posts/123).
1812
+ new MemoryHistory("/initial"); // In-memory only.
1813
+ ```
1814
+
1815
+ See [History implementations](#history-implementations) for detailed usage.
1816
+
1817
+ **`history.location()`** returns the current location.
1818
+
1819
+ - Returns: `HistoryLocation` - The current location with path, parsed search params, and history state
1820
+
1821
+ ```tsx
1822
+ const { path, search, state } = history.location();
1823
+ // path: "/users/42"
1824
+ // search: { tab: "posts", page: 2 }
1825
+ // state: any state passed during navigation
1826
+ ```
1827
+
1828
+ **`history.go(delta)`** navigates forward or back in history.
1829
+
1830
+ - `delta` - `number` - The number of entries to move
1831
+ - Returns: `void`
1832
+
1833
+ ```tsx
1834
+ history.go(-1); // Go back
1835
+ history.go(1); // Go forward
1836
+ history.go(-2); // Go back two steps
1837
+ ```
1838
+
1839
+ **`history.push(options)`** pushes or replaces a history entry.
1840
+
1841
+ - `options` - `HistoryPushOptions` - The URL to navigate to, with optional `replace` and `state`
1842
+ - Returns: `void`
1843
+
1844
+ ```tsx
1845
+ history.push({ url: "/users/42", state: { from: "list" } });
1846
+ history.push({ url: "/login", replace: true });
1847
+ ```
1848
+
1849
+ **`history.subscribe(listener)`** subscribes to navigation events.
1850
+
1851
+ - `listener` - `() => void` - Callback invoked when any navigation occurs
1852
+ - Returns: `() => void` - An unsubscribe function
1853
+
1854
+ ```tsx
1855
+ const unsubscribe = history.subscribe(() => {
1856
+ console.log("Navigation occurred");
1857
+ });
1858
+
1859
+ // Later: unsubscribe()
1860
+ ```
1861
+
1862
+ ## Types
1863
+
1864
+ **`RouterOptions`** are options for creating a `Router` instance or passing to `RouterRoot`.
1865
+
1866
+ ```tsx
1867
+ interface RouterOptions {
1868
+ routes: Route[]; // Array of navigable routes (required)
1869
+ basePath?: string; // Base path prefix (default: "/")
1870
+ history?: HistoryLike; // History implementation (default: BrowserHistory)
1871
+ ssrContext?: SSRContext; // Context for server-side rendering
1872
+ defaultLinkOptions?: LinkOptions; // Default options for all Link components
1873
+ }
1874
+ ```
1875
+
1876
+ **`NavigateOptions`** are options for type-safe navigation.
1877
+
1878
+ ```tsx
1879
+ type NavigateOptions = {
1880
+ to: Pattern | Route; // Route pattern string or route object
1881
+ params?: Params; // Path params
1882
+ search?: Search; // Search params
1883
+ replace?: boolean; // Replace history entry instead of pushing
1884
+ state?: any; // Arbitrary state to pass
1885
+ };
1886
+ ```
1887
+
1888
+ **`HistoryLocation`** represents a history location.
1889
+
1890
+ ```tsx
1891
+ interface HistoryLocation {
1892
+ path: string; // The current path
1893
+ search: Record<string, unknown>; // Parsed search params
1894
+ state: any; // History state passed during navigation
1895
+ }
1896
+ ```
1897
+
1898
+ **`HistoryPushOptions`** are options for untyped navigation.
1899
+
1900
+ ```tsx
1901
+ interface HistoryPushOptions {
1902
+ url: string; // The URL to navigate to
1903
+ replace?: boolean; // Replace history entry instead of pushing
1904
+ state?: any; // Arbitrary state to pass
1905
+ }
1906
+ ```
1907
+
1908
+ **`MatchOptions`** are options for route matching.
1909
+
1910
+ ```tsx
1911
+ type MatchOptions = {
1912
+ from: Pattern | Route; // The route to match against
1913
+ strict?: boolean; // Require exact match (default: false, matches prefixes)
1914
+ params?: Partial<Params>; // Optional param values to filter by
1915
+ };
1916
+ ```
1917
+
1918
+ **`Match`** is the result of a successful route match.
1919
+
1920
+ ```tsx
1921
+ type Match = {
1922
+ route: Route; // Matched route object
1923
+ params: Params; // Extracted path params
1924
+ };
1925
+ ```
1926
+
1927
+ **`LinkOptions`** controls link behavior and styling.
1928
+
1929
+ ```tsx
1930
+ interface LinkOptions {
1931
+ strict?: boolean; // Strict matching for active state detection
1932
+ preload?: "intent" | "render" | "viewport" | false; // When to trigger preloading
1933
+ preloadDelay?: number; // Delay in ms before preloading starts (default: 50)
1934
+ style?: CSSProperties; // Base styles for the link
1935
+ className?: string; // Base class name for the link
1936
+ activeStyle?: CSSProperties; // Additional styles when active
1937
+ activeClassName?: string; // Additional class name when active
1938
+ }
1939
+ ```
1940
+
1941
+ **`SSRContext`** captures context during server-side rendering.
1942
+
1943
+ ```tsx
1944
+ type SSRContext = {
1945
+ redirect?: string; // Set by Navigate component during SSR
1946
+ statusCode?: number; // Can be set manually for HTTP status
1947
+ };
1948
+ ```
1949
+
1950
+ **`PreloadContext`** is the context passed to preload functions.
1951
+
1952
+ ```tsx
1953
+ interface PreloadContext {
1954
+ params: Params; // Path params for the route
1955
+ search: Search; // Validated search params
1956
+ }
1957
+ ```
1958
+
1959
+ ---
1960
+
1961
+ # Roadmap
1962
+
1963
+ - Possibility to pass an arbitrary context to the Router instance for later use in preloads?
1964
+ - Relative path navigation? Not sure it's worth the extra bundle size given that users can export/import route objects and pass them as navigation option.
1965
+ - Document usage in test environments
1966
+ - Navigation blockers (`useBlocker`, etc.)
1967
+ - Open to suggestions, we can discuss them [here](https://github.com/strblr/typeroute/discussions).
1968
+
1969
+ ---
1970
+
1971
+ # License
1972
+
1973
+ MIT