@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 +1973 -0
- package/dist/index.d.ts +213 -0
- package/dist/index.js +1 -0
- package/package.json +59 -0
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
|