@tanstack/router-core 1.167.2 → 1.167.4
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/bin/intent.js +25 -0
- package/dist/cjs/load-matches.cjs +4 -1
- package/dist/cjs/load-matches.cjs.map +1 -1
- package/dist/cjs/router.cjs +2 -1
- package/dist/cjs/router.cjs.map +1 -1
- package/dist/esm/load-matches.js +4 -1
- package/dist/esm/load-matches.js.map +1 -1
- package/dist/esm/router.js +2 -1
- package/dist/esm/router.js.map +1 -1
- package/package.json +9 -2
- package/skills/router-core/SKILL.md +139 -0
- package/skills/router-core/auth-and-guards/SKILL.md +458 -0
- package/skills/router-core/code-splitting/SKILL.md +322 -0
- package/skills/router-core/data-loading/SKILL.md +485 -0
- package/skills/router-core/navigation/SKILL.md +448 -0
- package/skills/router-core/not-found-and-errors/SKILL.md +435 -0
- package/skills/router-core/path-params/SKILL.md +382 -0
- package/skills/router-core/search-params/SKILL.md +355 -0
- package/skills/router-core/search-params/references/validation-patterns.md +379 -0
- package/skills/router-core/ssr/SKILL.md +437 -0
- package/skills/router-core/type-safety/SKILL.md +497 -0
- package/src/load-matches.ts +4 -1
- package/src/router.ts +2 -1
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
# Search Param Validation Patterns Reference
|
|
2
|
+
|
|
3
|
+
Comprehensive validation patterns for TanStack Router search params across all supported validation approaches.
|
|
4
|
+
|
|
5
|
+
## Zod with `@tanstack/zod-adapter`
|
|
6
|
+
|
|
7
|
+
Zod v3 does not implement Standard Schema, so the `@tanstack/zod-adapter` wrapper is required. Always use `fallback()` from the adapter instead of zod's `.catch()`. Always wrap with `zodValidator()`.
|
|
8
|
+
|
|
9
|
+
### Basic Types
|
|
10
|
+
|
|
11
|
+
```tsx
|
|
12
|
+
import { zodValidator, fallback } from '@tanstack/zod-adapter'
|
|
13
|
+
import { z } from 'zod'
|
|
14
|
+
|
|
15
|
+
const schema = z.object({
|
|
16
|
+
count: fallback(z.number(), 0),
|
|
17
|
+
name: fallback(z.string(), ''),
|
|
18
|
+
active: fallback(z.boolean(), true),
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
export const Route = createFileRoute('/example')({
|
|
22
|
+
validateSearch: zodValidator(schema),
|
|
23
|
+
})
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Optional Params
|
|
27
|
+
|
|
28
|
+
```tsx
|
|
29
|
+
const schema = z.object({
|
|
30
|
+
// Truly optional — can be undefined in component
|
|
31
|
+
searchTerm: z.string().optional(),
|
|
32
|
+
// Optional in URL but always has a value in component
|
|
33
|
+
page: fallback(z.number(), 1).default(1),
|
|
34
|
+
})
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Default Values
|
|
38
|
+
|
|
39
|
+
```tsx
|
|
40
|
+
const schema = z.object({
|
|
41
|
+
// .default() means the param is optional during navigation
|
|
42
|
+
// but always present (with default) when reading
|
|
43
|
+
page: fallback(z.number(), 1).default(1),
|
|
44
|
+
sort: fallback(z.enum(['name', 'date', 'price']), 'name').default('name'),
|
|
45
|
+
ascending: fallback(z.boolean(), true).default(true),
|
|
46
|
+
})
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Array Params
|
|
50
|
+
|
|
51
|
+
```tsx
|
|
52
|
+
const schema = z.object({
|
|
53
|
+
tags: fallback(z.string().array(), []).default([]),
|
|
54
|
+
selectedIds: fallback(z.number().array(), []).default([]),
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
// URL: /items?tags=%5B%22react%22%2C%22typescript%22%5D&selectedIds=%5B1%2C2%2C3%5D
|
|
58
|
+
// Parsed: { tags: ['react', 'typescript'], selectedIds: [1, 2, 3] }
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Nested Object Params
|
|
62
|
+
|
|
63
|
+
```tsx
|
|
64
|
+
const schema = z.object({
|
|
65
|
+
filters: fallback(
|
|
66
|
+
z.object({
|
|
67
|
+
status: z.enum(['active', 'inactive']).optional(),
|
|
68
|
+
tags: z.string().array().optional(),
|
|
69
|
+
priceRange: z
|
|
70
|
+
.object({
|
|
71
|
+
min: z.number().min(0),
|
|
72
|
+
max: z.number().min(0),
|
|
73
|
+
})
|
|
74
|
+
.optional(),
|
|
75
|
+
}),
|
|
76
|
+
{},
|
|
77
|
+
).default({}),
|
|
78
|
+
})
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Enum with Constraints
|
|
82
|
+
|
|
83
|
+
```tsx
|
|
84
|
+
const schema = z.object({
|
|
85
|
+
sort: fallback(z.enum(['newest', 'oldest', 'price']), 'newest').default(
|
|
86
|
+
'newest',
|
|
87
|
+
),
|
|
88
|
+
page: fallback(z.number().int().min(1).max(1000), 1).default(1),
|
|
89
|
+
limit: fallback(z.number().int().min(10).max(100), 20).default(20),
|
|
90
|
+
})
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Discriminated Union
|
|
94
|
+
|
|
95
|
+
```tsx
|
|
96
|
+
const schema = z.object({
|
|
97
|
+
searchType: fallback(z.enum(['basic', 'advanced']), 'basic').default('basic'),
|
|
98
|
+
query: fallback(z.string(), '').default(''),
|
|
99
|
+
// Advanced-only fields are optional
|
|
100
|
+
category: z.string().optional(),
|
|
101
|
+
minPrice: z.number().optional(),
|
|
102
|
+
maxPrice: z.number().optional(),
|
|
103
|
+
})
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
For true discriminated union validation:
|
|
107
|
+
|
|
108
|
+
```tsx
|
|
109
|
+
const basicSearch = z.object({
|
|
110
|
+
searchType: z.literal('basic'),
|
|
111
|
+
query: z.string(),
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
const advancedSearch = z.object({
|
|
115
|
+
searchType: z.literal('advanced'),
|
|
116
|
+
query: z.string(),
|
|
117
|
+
category: z.string(),
|
|
118
|
+
minPrice: z.number(),
|
|
119
|
+
maxPrice: z.number(),
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const schema = z.discriminatedUnion('searchType', [basicSearch, advancedSearch])
|
|
123
|
+
|
|
124
|
+
export const Route = createFileRoute('/search')({
|
|
125
|
+
validateSearch: zodValidator(schema),
|
|
126
|
+
})
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Input Transforms (String to Number)
|
|
130
|
+
|
|
131
|
+
When using the zod adapter with transforms, configure `input` and `output` types:
|
|
132
|
+
|
|
133
|
+
```tsx
|
|
134
|
+
const schema = z.object({
|
|
135
|
+
page: fallback(z.number(), 1).default(1),
|
|
136
|
+
filter: fallback(z.string(), '').default(''),
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
export const Route = createFileRoute('/items')({
|
|
140
|
+
// Default: input type used for navigation, output type used for reading
|
|
141
|
+
validateSearch: zodValidator(schema),
|
|
142
|
+
|
|
143
|
+
// Advanced: swap input/output inference
|
|
144
|
+
// validateSearch: zodValidator({ schema, input: 'output', output: 'input' }),
|
|
145
|
+
})
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Schema Composition
|
|
149
|
+
|
|
150
|
+
```tsx
|
|
151
|
+
const paginationSchema = z.object({
|
|
152
|
+
page: fallback(z.number().int().positive(), 1).default(1),
|
|
153
|
+
limit: fallback(z.number().int().min(1).max(100), 20).default(20),
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
const sortSchema = z.object({
|
|
157
|
+
sortBy: z.enum(['name', 'date', 'relevance']).optional(),
|
|
158
|
+
sortOrder: z.enum(['asc', 'desc']).optional(),
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
// Compose for specific routes
|
|
162
|
+
const productSearchSchema = paginationSchema.extend({
|
|
163
|
+
category: z.string().optional(),
|
|
164
|
+
inStock: fallback(z.boolean(), true).default(true),
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
const userSearchSchema = paginationSchema.merge(sortSchema).extend({
|
|
168
|
+
role: z.enum(['admin', 'user']).optional(),
|
|
169
|
+
})
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## Valibot (Standard Schema)
|
|
175
|
+
|
|
176
|
+
Valibot 1.0+ implements Standard Schema. No adapter wrapper needed — pass the schema directly to `validateSearch`. The `@tanstack/valibot-adapter` is optional and only needed for explicit input/output type control.
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
npm install valibot
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
```tsx
|
|
183
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
184
|
+
import * as v from 'valibot'
|
|
185
|
+
|
|
186
|
+
const productSearchSchema = v.object({
|
|
187
|
+
page: v.optional(v.fallback(v.number(), 1), 1),
|
|
188
|
+
filter: v.optional(v.fallback(v.string(), ''), ''),
|
|
189
|
+
sort: v.optional(
|
|
190
|
+
v.fallback(v.picklist(['newest', 'oldest', 'price']), 'newest'),
|
|
191
|
+
'newest',
|
|
192
|
+
),
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
export const Route = createFileRoute('/products')({
|
|
196
|
+
// Pass schema directly — Standard Schema compliant
|
|
197
|
+
validateSearch: productSearchSchema,
|
|
198
|
+
component: ProductsPage,
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
function ProductsPage() {
|
|
202
|
+
const { page, filter, sort } = Route.useSearch()
|
|
203
|
+
return <div>Page {page}</div>
|
|
204
|
+
}
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### Valibot with Constraints
|
|
208
|
+
|
|
209
|
+
```tsx
|
|
210
|
+
import * as v from 'valibot'
|
|
211
|
+
|
|
212
|
+
const schema = v.object({
|
|
213
|
+
page: v.optional(
|
|
214
|
+
v.fallback(v.pipe(v.number(), v.integer(), v.minValue(1)), 1),
|
|
215
|
+
1,
|
|
216
|
+
),
|
|
217
|
+
query: v.optional(v.pipe(v.string(), v.minLength(1), v.maxLength(100))),
|
|
218
|
+
tags: v.optional(v.fallback(v.array(v.string()), []), []),
|
|
219
|
+
})
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### Valibot with Adapter (Alternative)
|
|
223
|
+
|
|
224
|
+
If you need explicit input/output type control:
|
|
225
|
+
|
|
226
|
+
```tsx
|
|
227
|
+
import { valibotValidator } from '@tanstack/valibot-adapter'
|
|
228
|
+
import * as v from 'valibot'
|
|
229
|
+
|
|
230
|
+
const schema = v.object({
|
|
231
|
+
page: v.optional(v.fallback(v.number(), 1), 1),
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
export const Route = createFileRoute('/items')({
|
|
235
|
+
validateSearch: valibotValidator(schema),
|
|
236
|
+
})
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
---
|
|
240
|
+
|
|
241
|
+
## ArkType (Standard Schema)
|
|
242
|
+
|
|
243
|
+
ArkType 2.0-rc+ implements Standard Schema. No adapter needed — pass the type directly to `validateSearch`. The `@tanstack/arktype-adapter` is optional and only needed for explicit input/output type control.
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
npm install arktype
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
```tsx
|
|
250
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
251
|
+
import { type } from 'arktype'
|
|
252
|
+
|
|
253
|
+
const productSearchSchema = type({
|
|
254
|
+
page: 'number = 1',
|
|
255
|
+
filter: 'string = ""',
|
|
256
|
+
sort: '"newest" | "oldest" | "price" = "newest"',
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
export const Route = createFileRoute('/products')({
|
|
260
|
+
// Pass directly — Standard Schema compliant
|
|
261
|
+
validateSearch: productSearchSchema,
|
|
262
|
+
component: ProductsPage,
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
function ProductsPage() {
|
|
266
|
+
const { page, filter, sort } = Route.useSearch()
|
|
267
|
+
return <div>Page {page}</div>
|
|
268
|
+
}
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### ArkType with Constraints
|
|
272
|
+
|
|
273
|
+
```tsx
|
|
274
|
+
import { type } from 'arktype'
|
|
275
|
+
|
|
276
|
+
const searchSchema = type({
|
|
277
|
+
'query?': 'string>0&<=100',
|
|
278
|
+
page: 'number>0 = 1',
|
|
279
|
+
'sortBy?': "'name'|'date'|'relevance'",
|
|
280
|
+
'filters?': 'string[]',
|
|
281
|
+
})
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
---
|
|
285
|
+
|
|
286
|
+
## Manual Validation Function
|
|
287
|
+
|
|
288
|
+
For full control without any library. The function receives raw JSON-parsed (but unvalidated) search params.
|
|
289
|
+
|
|
290
|
+
```tsx
|
|
291
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
292
|
+
|
|
293
|
+
type ProductSearch = {
|
|
294
|
+
page: number
|
|
295
|
+
filter: string
|
|
296
|
+
sort: 'newest' | 'oldest' | 'price'
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export const Route = createFileRoute('/products')({
|
|
300
|
+
validateSearch: (search: Record<string, unknown>): ProductSearch => ({
|
|
301
|
+
page: Number(search?.page ?? 1),
|
|
302
|
+
filter: (search.filter as string) || '',
|
|
303
|
+
sort:
|
|
304
|
+
search.sort === 'newest' ||
|
|
305
|
+
search.sort === 'oldest' ||
|
|
306
|
+
search.sort === 'price'
|
|
307
|
+
? search.sort
|
|
308
|
+
: 'newest',
|
|
309
|
+
}),
|
|
310
|
+
component: ProductsPage,
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
function ProductsPage() {
|
|
314
|
+
const { page, filter, sort } = Route.useSearch()
|
|
315
|
+
return <div>Page {page}</div>
|
|
316
|
+
}
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
### Manual Validation with Error Throwing
|
|
320
|
+
|
|
321
|
+
If `validateSearch` throws, the route's `errorComponent` renders instead:
|
|
322
|
+
|
|
323
|
+
```tsx
|
|
324
|
+
export const Route = createFileRoute('/products')({
|
|
325
|
+
validateSearch: (search: Record<string, unknown>) => {
|
|
326
|
+
const page = Number(search.page)
|
|
327
|
+
if (isNaN(page) || page < 1) {
|
|
328
|
+
throw new Error('Invalid page number')
|
|
329
|
+
}
|
|
330
|
+
return { page }
|
|
331
|
+
},
|
|
332
|
+
errorComponent: ({ error }) => <div>Bad search params: {error.message}</div>,
|
|
333
|
+
})
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
---
|
|
337
|
+
|
|
338
|
+
## Pattern: Object with `parse` Method
|
|
339
|
+
|
|
340
|
+
Any object with a `.parse()` method works as `validateSearch`:
|
|
341
|
+
|
|
342
|
+
```tsx
|
|
343
|
+
const mySchema = {
|
|
344
|
+
parse: (input: Record<string, unknown>) => ({
|
|
345
|
+
page: Number(input.page ?? 1),
|
|
346
|
+
query: String(input.query ?? ''),
|
|
347
|
+
}),
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export const Route = createFileRoute('/search')({
|
|
351
|
+
validateSearch: mySchema,
|
|
352
|
+
})
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
---
|
|
356
|
+
|
|
357
|
+
## Dates in Search Params
|
|
358
|
+
|
|
359
|
+
Never put `Date` objects in search params. Always use ISO strings:
|
|
360
|
+
|
|
361
|
+
```tsx
|
|
362
|
+
const schema = z.object({
|
|
363
|
+
// Store as string, parse in component if needed
|
|
364
|
+
startDate: z.string().optional(),
|
|
365
|
+
endDate: z.string().optional(),
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
// In component:
|
|
369
|
+
function DateFilter() {
|
|
370
|
+
const { startDate } = Route.useSearch()
|
|
371
|
+
const date = startDate ? new Date(startDate) : null
|
|
372
|
+
return <div>{date?.toLocaleDateString()}</div>
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// When navigating:
|
|
376
|
+
;<Link search={(prev) => ({ ...prev, startDate: new Date().toISOString() })}>
|
|
377
|
+
Set Start Date
|
|
378
|
+
</Link>
|
|
379
|
+
```
|