references/validation-patterns.md
references/validation-patterns.mdBrowse 2 files
9,319 bytes
Token encoding: o200k_base
Snapshot 8e164d2
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
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
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
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
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
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
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
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:
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:
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
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.
npm install valibot
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
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:
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.
npm install arktype
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
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.
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:
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 instanceof Error ? error.message : String(error)}
</div>
),
})
Pattern: Object with parse Method
Any object with a .parse() method works as validateSearch:
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:
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>