Files
findyourpilot/.agent/skills/tanstack-router-best-practices/rules/search-validation.md
2026-03-02 21:16:26 +01:00

4.1 KiB

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

// 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

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

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

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

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