Learn how to add robust schema validation to your search parameters using popular validation libraries like Zod, Valibot, and ArkType. This guide covers validation setup, error handling, type safety, and common validation patterns for production applications.
Prerequisites: Set Up Basic Search Parameters - Foundation concepts for reading and working with search params.
Add robust validation with custom error messages, complex types, and production-ready error handling:
import { createFileRoute, useRouter } from '@tanstack/react-router'
import { zodValidator, fallback } from '@tanstack/zod-adapter'
import { z } from 'zod'
const productSearchSchema = z.object({
query: z.string().min(1, 'Search query required'),
category: z.enum(['electronics', 'clothing', 'books', 'home']).optional(),
minPrice: fallback(z.number().min(0, 'Price cannot be negative'), 0),
maxPrice: fallback(z.number().min(0, 'Price cannot be negative'), 1000),
inStock: fallback(z.boolean(), true),
tags: z.array(z.string()).optional(),
dateRange: z
.object({
start: z.string().datetime().optional(),
end: z.string().datetime().optional(),
})
.optional(),
})
export const Route = createFileRoute('/products')({
validateSearch: zodValidator(productSearchSchema),
errorComponent: ({ error }) => {
const router = useRouter()
return (
<div className="error">
<h2>Invalid Search Parameters</h2>
<p>{error.message}</p>
<button
onClick={() => router.navigate({ to: '/products', search: {} })}
>
Reset Search
</button>
</div>
)
},
component: ProductsPage,
})
function ProductsPage() {
// All search params are validated, type-safe, and have fallback values applied
const { query, category, minPrice, maxPrice, inStock, tags, dateRange } =
Route.useSearch()
return (
<div>
<h1>Products</h1>
<p>Search: {query}</p>
<p>Category: {category || 'All'}</p>
<p>
Price Range: ${minPrice} - ${maxPrice}
</p>
<p>In Stock Only: {inStock ? 'Yes' : 'No'}</p>
{tags && <p>Tags: {tags.join(', ')}</p>}
{dateRange && (
<p>
Date Range: {dateRange.start} to {dateRange.end}
</p>
)}
</div>
)
}
TanStack Router supports multiple validation libraries through adapters:
Most popular with excellent TypeScript integration:
import { zodValidator, fallback } from '@tanstack/zod-adapter'
import { z } from 'zod'
const searchSchema = z.object({
query: z.string().min(1).max(100),
page: fallback(z.number().int().positive(), 1),
sortBy: z.enum(['name', 'date', 'relevance']).optional(),
filters: z.array(z.string()).optional(),
})
export const Route = createFileRoute('/search')({
validateSearch: zodValidator(searchSchema),
component: SearchPage,
})
Lightweight alternative with modular design:
import { valibotValidator } from '@tanstack/valibot-adapter'
import * as v from 'valibot'
const searchSchema = v.object({
query: v.pipe(v.string(), v.minLength(1), v.maxLength(100)),
page: v.fallback(v.pipe(v.number(), v.integer(), v.minValue(1)), 1),
sortBy: v.optional(v.picklist(['name', 'date', 'relevance'])),
filters: v.optional(v.array(v.string())),
})
export const Route = createFileRoute('/search')({
validateSearch: valibotValidator(searchSchema),
component: SearchPage,
})
TypeScript-first with runtime validation:
import { type } from 'arktype'
const searchSchema = type({
query: 'string>0&<=100',
page: 'number>0 = 1',
'sortBy?': "'name'|'date'|'relevance'",
'filters?': 'string[]',
})
export const Route = createFileRoute('/search')({
validateSearch: searchSchema,
component: SearchPage,
})
For complete control, implement your own validation logic:
export const Route = createFileRoute('/search')({
validateSearch: (search: Record<string, unknown>) => {
// Custom validation with detailed error handling
const result = {
page: 1,
query: '',
category: 'all',
}
// Validate page number
const pageNum = Number(search.page)
if (isNaN(pageNum) || pageNum < 1) {
throw new Error('Page must be a positive number')
}
result.page = pageNum
// Validate query string
if (typeof search.query === 'string' && search.query.length > 0) {
if (search.query.length > 100) {
throw new Error('Search query too long (max 100 characters)')
}
result.query = search.query
}
// Validate category
const validCategories = ['electronics', 'clothing', 'books', 'all']
if (
typeof search.category === 'string' &&
validCategories.includes(search.category)
) {
result.category = search.category
}
return result
},
component: SearchPage,
})
Control which search parameters are mandatory:
const validationSchema = z.object({
// Required - will throw validation error if missing or invalid
userId: z.number().int().positive(),
action: z.enum(['view', 'edit', 'delete']),
// Optional - can be undefined
sortBy: z.string().optional(),
// Optional with fallback - provides default if missing/invalid
page: fallback(z.number().int().positive(), 1),
limit: fallback(z.number().int().min(1).max(100), 20),
})
Handle arrays, objects, and custom types:
const advancedSchema = z.object({
// Array of strings
tags: z.array(z.string()).optional(),
// Array of numbers
categoryIds: z.array(z.number().int()).optional(),
// Date validation
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(),
// Custom validation
email: z.string().email().optional(),
// Refined validation with custom logic
priceRange: z
.object({
min: z.number().min(0),
max: z.number().min(0),
})
.refine((data) => data.max >= data.min, {
message: 'Max price must be greater than or equal to min price',
})
.optional(),
})
Transform and sanitize input values during validation:
const transformSchema = z.object({
// Transform string to number
page: z
.string()
.transform((val) => parseInt(val, 10))
.pipe(z.number().int().positive()),
// Transform and validate email
email: z.string().toLowerCase().trim().pipe(z.string().email()).optional(),
// Transform comma-separated string to array
tags: z
.string()
.transform((val) => (val ? val.split(',').map((tag) => tag.trim()) : []))
.pipe(z.array(z.string().min(1)))
.optional(),
})
Handle validation errors through route error components:
import { createFileRoute, useRouter } from '@tanstack/react-router'
import { zodValidator } from '@tanstack/zod-adapter'
import { z } from 'zod'
const searchSchema = z.object({
query: z.string().min(1, 'Search query is required'),
page: z.number().int().positive('Page must be a positive number'),
})
export const Route = createFileRoute('/search')({
validateSearch: zodValidator(searchSchema),
errorComponent: ({ error }) => {
const router = useRouter()
return (
<div className="error">
<h2>Invalid Search Parameters</h2>
<p>{error.message}</p>
<button onClick={() => router.navigate({ to: '/search', search: {} })}>
Reset Search
</button>
<button
onClick={() =>
router.navigate({ to: '/search', search: { query: '', page: 1 } })
}
>
Start Over
</button>
</div>
)
},
component: SearchPage,
})
function SearchPage() {
// Only called when validation succeeds
const search = Route.useSearch()
// ... rest of component
}
Provide user-friendly validation messages:
const userFriendlySchema = z.object({
query: z
.string()
.min(2, 'Search query must be at least 2 characters')
.max(100, 'Search query cannot exceed 100 characters'),
page: fallback(
z
.number()
.int('Page must be a whole number')
.positive('Page must be greater than 0'),
1,
),
category: z
.enum(['electronics', 'clothing', 'books'], {
errorMap: () => ({ message: 'Please select a valid category' }),
})
.optional(),
})
Implement fallback behavior for invalid parameters:
const resilientSchema = z.object({
// Use .catch() to provide fallback values on validation failure
page: z.number().int().positive().catch(1),
// Use .default() for missing values, .catch() for invalid values
sortBy: z
.enum(['name', 'date', 'relevance'])
.default('relevance')
.catch('relevance'),
// Custom recovery logic
dateRange: z
.object({
start: z.string().datetime(),
end: z.string().datetime(),
})
.catch({
start: new Date().toISOString(),
end: new Date().toISOString(),
})
.optional(),
})
Apply different validation rules based on other parameters:
const conditionalSchema = z
.object({
searchType: z.enum(['basic', 'advanced']),
query: z.string().min(1),
})
.and(
z.discriminatedUnion('searchType', [
z.object({
searchType: z.literal('basic'),
// Basic search requires only query
}),
z.object({
searchType: z.literal('advanced'),
// Advanced search requires additional fields
category: z.string().min(1),
minPrice: z.number().min(0),
maxPrice: z.number().min(0),
}),
]),
)
Combine and extend schemas for reusability:
// Base pagination schema
const paginationSchema = z.object({
page: fallback(z.number().int().positive(), 1),
limit: fallback(z.number().int().min(1).max(100), 20),
})
// Base filter schema
const filterSchema = z.object({
sortBy: z.enum(['name', 'date', 'relevance']).optional(),
sortOrder: z.enum(['asc', 'desc']).optional(),
})
// Compose schemas for different routes
const productSearchSchema = paginationSchema.extend({
category: z.string().optional(),
inStock: fallback(z.boolean(), true),
})
const userSearchSchema = paginationSchema.merge(filterSchema).extend({
role: z.enum(['admin', 'user', 'moderator']).optional(),
isActive: fallback(z.boolean(), true),
})
Optimize validation for better performance:
// Pre-compile schemas for better performance
const compiledSchema = zodValidator(
z.object({
query: z.string().min(1),
page: fallback(z.number().int().positive(), 1),
}),
)
export const Route = createFileRoute('/search')({
validateSearch: compiledSchema,
component: SearchPage,
})
// Use selective validation for expensive operations
function SearchPage() {
// Only validate specific fields when needed
const search = Route.useSearch({
select: (search) => ({
query: search.query,
page: search.page,
}),
})
return <div>Search Results</div>
}
Focus on testing validation behavior specific to your schemas:
import { render, screen, waitFor } from '@testing-library/react'
import {
createRouter,
createMemoryHistory,
RouterProvider,
} from '@tanstack/react-router'
describe('Search Validation Behavior', () => {
it('should show error component when validation fails', async () => {
const router = createRouter({
routeTree,
history: createMemoryHistory({
initialEntries: ['/search?page=invalid&query='],
}),
})
render(<RouterProvider router={router} />)
await waitFor(() => {
expect(screen.getByText('Invalid Search Parameters')).toBeInTheDocument()
})
})
it('should apply fallback values correctly', async () => {
const router = createRouter({
routeTree,
history: createMemoryHistory({
initialEntries: ['/search?query=laptops'], // page missing
}),
})
render(<RouterProvider router={router} />)
await waitFor(() => {
expect(screen.getByText('Page: 1')).toBeInTheDocument() // Fallback applied
})
})
})
For comprehensive route testing patterns, see: Set Up Testing and Test File-Based Routing
Symptoms: Page fails to load when URL contains invalid search parameters.
Solution: Use fallback values and error boundaries:
// ❌ Wrong - will throw error and break route
const strictSchema = z.object({
page: z.number().int().positive(), // No fallback
})
// ✅ Correct - provides fallback for invalid values
const resilientSchema = z.object({
page: fallback(z.number().int().positive(), 1),
})
// ✅ Alternative - use errorComponent on route
export const Route = createFileRoute('/search')({
validateSearch: resilientSchema,
errorComponent: ({ error }) => <SearchError error={error} />,
component: SearchPage,
})
function SearchPage() {
// Only called when validation succeeds
const search = Route.useSearch()
return <SearchResults search={search} />
}
Symptoms: TypeScript complains about potentially undefined values.
Solution: Use proper optional handling or fallback values:
// ❌ Wrong - category might be undefined
function FilterBar() {
const { category } = Route.useSearch()
return <span>{category.toUpperCase()}</span> // TypeScript error
}
// ✅ Correct - handle optional values
function FilterBar() {
const { category } = Route.useSearch()
return <span>{category?.toUpperCase() || 'All Categories'}</span>
}
// ✅ Better - use fallback in schema
const schema = z.object({
category: fallback(z.string(), 'all'),
})
Symptoms: Array values appear as strings instead of arrays.
Solution: Ensure proper array parsing in your schema:
// ❌ Wrong - doesn't handle URL array format
const badSchema = z.object({
tags: z.array(z.string()).optional(),
})
// ✅ Correct - parse comma-separated values or multiple params
const goodSchema = z.object({
tags: z
.union([
z.array(z.string()), // Multiple ?tags=a&tags=b
z.string().transform((val) => val.split(',')), // Single ?tags=a,b,c
])
.optional(),
})
// ✅ Alternative - custom preprocessing
const preprocessedSchema = z.preprocess((val) => {
if (typeof val === 'string') return val.split(',')
return val
}, z.array(z.string()).optional())
Symptoms: Noticeable delay when navigating with complex search parameters.
Solution: Optimize schema complexity and use selective parsing:
// ❌ Slow - complex validation on every navigation
const complexSchema = z.object({
query: z.string().refine(async (val) => await validateQuery(val)),
// ... many complex validations
})
// ✅ Fast - simplified validation with lazy refinement
const optimizedSchema = z.object({
query: z.string().min(1), // Basic validation only
// ... other simple validations
})
// Perform complex validation separately in component
function SearchPage() {
const search = Route.useSearch()
// Complex validation only when needed
const [complexValidation, setComplexValidation] = useState(null)
useEffect(() => {
validateComplexRules(search).then(setComplexValidation)
}, [search])
return <SearchResults search={search} validation={complexValidation} />
}
After setting up schema validation, you might want to: