@tanstack/router-core
Version:
Modern and scalable routing for React applications
380 lines (296 loc) • 9.24 kB
Markdown
# Search Param Validation Patterns Reference
Comprehensive validation patterns for TanStack Router search params across all supported validation approaches.
## Zod with `@tanstack/zod-adapter`
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()`.
### Basic Types
```tsx
import { zodValidator, fallback } from '@tanstack/zod-adapter'
import { z } from 'zod'
const schema = z.object({
count: fallback(z.number(), 0),
name: fallback(z.string(), ''),
active: fallback(z.boolean(), true),
})
export const Route = createFileRoute('/example')({
validateSearch: zodValidator(schema),
})
```
### Optional Params
```tsx
const schema = z.object({
// Truly optional — can be undefined in component
searchTerm: z.string().optional(),
// Optional in URL but always has a value in component
page: fallback(z.number(), 1).default(1),
})
```
### Default Values
```tsx
const schema = z.object({
// .default() means the param is optional during navigation
// but always present (with default) when reading
page: fallback(z.number(), 1).default(1),
sort: fallback(z.enum(['name', 'date', 'price']), 'name').default('name'),
ascending: fallback(z.boolean(), true).default(true),
})
```
### Array Params
```tsx
const schema = z.object({
tags: fallback(z.string().array(), []).default([]),
selectedIds: fallback(z.number().array(), []).default([]),
})
// URL: /items?tags=%5B%22react%22%2C%22typescript%22%5D&selectedIds=%5B1%2C2%2C3%5D
// Parsed: { tags: ['react', 'typescript'], selectedIds: [1, 2, 3] }
```
### Nested Object Params
```tsx
const schema = z.object({
filters: fallback(
z.object({
status: z.enum(['active', 'inactive']).optional(),
tags: z.string().array().optional(),
priceRange: z
.object({
min: z.number().min(0),
max: z.number().min(0),
})
.optional(),
}),
{},
).default({}),
})
```
### Enum with Constraints
```tsx
const schema = z.object({
sort: fallback(z.enum(['newest', 'oldest', 'price']), 'newest').default(
'newest',
),
page: fallback(z.number().int().min(1).max(1000), 1).default(1),
limit: fallback(z.number().int().min(10).max(100), 20).default(20),
})
```
### Discriminated Union
```tsx
const schema = z.object({
searchType: fallback(z.enum(['basic', 'advanced']), 'basic').default('basic'),
query: fallback(z.string(), '').default(''),
// Advanced-only fields are optional
category: z.string().optional(),
minPrice: z.number().optional(),
maxPrice: z.number().optional(),
})
```
For true discriminated union validation:
```tsx
const basicSearch = z.object({
searchType: z.literal('basic'),
query: z.string(),
})
const advancedSearch = z.object({
searchType: z.literal('advanced'),
query: z.string(),
category: z.string(),
minPrice: z.number(),
maxPrice: z.number(),
})
const schema = z.discriminatedUnion('searchType', [basicSearch, advancedSearch])
export const Route = createFileRoute('/search')({
validateSearch: zodValidator(schema),
})
```
### Input Transforms (String to Number)
When using the zod adapter with transforms, configure `input` and `output` types:
```tsx
const schema = z.object({
page: fallback(z.number(), 1).default(1),
filter: fallback(z.string(), '').default(''),
})
export const Route = createFileRoute('/items')({
// Default: input type used for navigation, output type used for reading
validateSearch: zodValidator(schema),
// Advanced: swap input/output inference
// validateSearch: zodValidator({ schema, input: 'output', output: 'input' }),
})
```
### Schema Composition
```tsx
const paginationSchema = z.object({
page: fallback(z.number().int().positive(), 1).default(1),
limit: fallback(z.number().int().min(1).max(100), 20).default(20),
})
const sortSchema = z.object({
sortBy: z.enum(['name', 'date', 'relevance']).optional(),
sortOrder: z.enum(['asc', 'desc']).optional(),
})
// Compose for specific routes
const productSearchSchema = paginationSchema.extend({
category: z.string().optional(),
inStock: fallback(z.boolean(), true).default(true),
})
const userSearchSchema = paginationSchema.merge(sortSchema).extend({
role: z.enum(['admin', 'user']).optional(),
})
```
## Valibot (Standard Schema)
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.
```bash
npm install valibot
```
```tsx
import { createFileRoute } from '@tanstack/react-router'
import * as v from 'valibot'
const productSearchSchema = v.object({
page: v.optional(v.fallback(v.number(), 1), 1),
filter: v.optional(v.fallback(v.string(), ''), ''),
sort: v.optional(
v.fallback(v.picklist(['newest', 'oldest', 'price']), 'newest'),
'newest',
),
})
export const Route = createFileRoute('/products')({
// Pass schema directly — Standard Schema compliant
validateSearch: productSearchSchema,
component: ProductsPage,
})
function ProductsPage() {
const { page, filter, sort } = Route.useSearch()
return <div>Page {page}</div>
}
```
### Valibot with Constraints
```tsx
import * as v from 'valibot'
const schema = v.object({
page: v.optional(
v.fallback(v.pipe(v.number(), v.integer(), v.minValue(1)), 1),
1,
),
query: v.optional(v.pipe(v.string(), v.minLength(1), v.maxLength(100))),
tags: v.optional(v.fallback(v.array(v.string()), []), []),
})
```
### Valibot with Adapter (Alternative)
If you need explicit input/output type control:
```tsx
import { valibotValidator } from '@tanstack/valibot-adapter'
import * as v from 'valibot'
const schema = v.object({
page: v.optional(v.fallback(v.number(), 1), 1),
})
export const Route = createFileRoute('/items')({
validateSearch: valibotValidator(schema),
})
```
## ArkType (Standard Schema)
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.
```bash
npm install arktype
```
```tsx
import { createFileRoute } from '@tanstack/react-router'
import { type } from 'arktype'
const productSearchSchema = type({
page: 'number = 1',
filter: 'string = ""',
sort: '"newest" | "oldest" | "price" = "newest"',
})
export const Route = createFileRoute('/products')({
// Pass directly — Standard Schema compliant
validateSearch: productSearchSchema,
component: ProductsPage,
})
function ProductsPage() {
const { page, filter, sort } = Route.useSearch()
return <div>Page {page}</div>
}
```
### ArkType with Constraints
```tsx
import { type } from 'arktype'
const searchSchema = type({
'query?': 'string>0&<=100',
page: 'number>0 = 1',
'sortBy?': "'name'|'date'|'relevance'",
'filters?': 'string[]',
})
```
## Manual Validation Function
For full control without any library. The function receives raw JSON-parsed (but unvalidated) search params.
```tsx
import { createFileRoute } from '@tanstack/react-router'
type ProductSearch = {
page: number
filter: string
sort: 'newest' | 'oldest' | 'price'
}
export const Route = createFileRoute('/products')({
validateSearch: (search: Record<string, unknown>): ProductSearch => ({
page: Number(search?.page ?? 1),
filter: (search.filter as string) || '',
sort:
search.sort === 'newest' ||
search.sort === 'oldest' ||
search.sort === 'price'
? search.sort
: 'newest',
}),
component: ProductsPage,
})
function ProductsPage() {
const { page, filter, sort } = Route.useSearch()
return <div>Page {page}</div>
}
```
### Manual Validation with Error Throwing
If `validateSearch` throws, the route's `errorComponent` renders instead:
```tsx
export const Route = createFileRoute('/products')({
validateSearch: (search: Record<string, unknown>) => {
const page = Number(search.page)
if (isNaN(page) || page < 1) {
throw new Error('Invalid page number')
}
return { page }
},
errorComponent: ({ error }) => <div>Bad search params: {error.message}</div>,
})
```
## Pattern: Object with `parse` Method
Any object with a `.parse()` method works as `validateSearch`:
```tsx
const mySchema = {
parse: (input: Record<string, unknown>) => ({
page: Number(input.page ?? 1),
query: String(input.query ?? ''),
}),
}
export const Route = createFileRoute('/search')({
validateSearch: mySchema,
})
```
## Dates in Search Params
Never put `Date` objects in search params. Always use ISO strings:
```tsx
const schema = z.object({
// Store as string, parse in component if needed
startDate: z.string().optional(),
endDate: z.string().optional(),
})
// In component:
function DateFilter() {
const { startDate } = Route.useSearch()
const date = startDate ? new Date(startDate) : null
return <div>{date?.toLocaleDateString()}</div>
}
// When navigating:
;<Link search={(prev) => ({ ...prev, startDate: new Date().toISOString() })}>
Set Start Date
</Link>
```