Files
2026-03-02 21:16:26 +01:00

159 lines
4.1 KiB
Markdown

# search-validation: Always Validate Search Params
## Priority: HIGH
## Explanation
Search params come from the URL - user-controlled input that must be validated. Use `validateSearch` to parse, validate, and provide defaults. This ensures type safety and prevents runtime errors from malformed URLs.
## Bad Example
```tsx
// No validation - trusting URL input directly
export const Route = createFileRoute('/products')({
component: ProductsPage,
})
function ProductsPage() {
// Accessing raw search params without validation
const searchParams = new URLSearchParams(window.location.search)
const page = parseInt(searchParams.get('page') || '1') // Could be NaN
const sort = searchParams.get('sort') as 'asc' | 'desc' // Could be anything
// Runtime errors possible if URL is malformed
return <ProductList page={page} sort={sort} />
}
```
## Good Example: Manual Validation
```tsx
export const Route = createFileRoute('/products')({
validateSearch: (search: Record<string, unknown>) => {
return {
page: Number(search.page) || 1,
sort: search.sort === 'desc' ? 'desc' : 'asc',
category: typeof search.category === 'string' ? search.category : undefined,
minPrice: Number(search.minPrice) || undefined,
maxPrice: Number(search.maxPrice) || undefined,
}
},
component: ProductsPage,
})
function ProductsPage() {
// Fully typed, validated search params
const { page, sort, category, minPrice, maxPrice } = Route.useSearch()
// page: number (default 1)
// sort: 'asc' | 'desc' (default 'asc')
// category: string | undefined
}
```
## Good Example: With Zod
```tsx
import { z } from 'zod'
const productSearchSchema = z.object({
page: z.number().min(1).catch(1),
limit: z.number().min(1).max(100).catch(20),
sort: z.enum(['name', 'price', 'date']).catch('name'),
order: z.enum(['asc', 'desc']).catch('asc'),
category: z.string().optional(),
search: z.string().optional(),
minPrice: z.number().min(0).optional(),
maxPrice: z.number().min(0).optional(),
})
type ProductSearch = z.infer<typeof productSearchSchema>
export const Route = createFileRoute('/products')({
validateSearch: (search) => productSearchSchema.parse(search),
component: ProductsPage,
})
function ProductsPage() {
const search = Route.useSearch()
// search: ProductSearch - fully typed with defaults
return (
<ProductList
page={search.page}
limit={search.limit}
sort={search.sort}
order={search.order}
filters={{
category: search.category,
search: search.search,
priceRange: search.minPrice && search.maxPrice
? [search.minPrice, search.maxPrice]
: undefined,
}}
/>
)
}
```
## Good Example: With Valibot
```tsx
import * as v from 'valibot'
import { valibotSearchValidator } from '@tanstack/router-valibot-adapter'
const searchSchema = v.object({
page: v.fallback(v.number(), 1),
query: v.fallback(v.string(), ''),
filters: v.fallback(
v.array(v.string()),
[]
),
})
export const Route = createFileRoute('/search')({
validateSearch: valibotSearchValidator(searchSchema),
component: SearchPage,
})
```
## Updating Search Params
```tsx
function ProductFilters() {
const navigate = useNavigate()
const search = Route.useSearch()
const updateFilters = (newFilters: Partial<ProductSearch>) => {
navigate({
to: '.', // Current route
search: (prev) => ({
...prev,
...newFilters,
page: 1, // Reset to page 1 when filters change
}),
})
}
return (
<div>
<select
value={search.sort}
onChange={(e) => updateFilters({ sort: e.target.value as ProductSearch['sort'] })}
>
<option value="name">Name</option>
<option value="price">Price</option>
<option value="date">Date</option>
</select>
</div>
)
}
```
## Context
- Search params are user input - never trust them unvalidated
- Use `.catch()` in Zod or `fallback()` in Valibot for graceful defaults
- Validation runs on every navigation - keep it fast
- Search params are inherited by child routes
- Use `search` updater function to preserve other params