TanStack Router 08 ⚛️ Search Params — Zod validation & parsing
The previous chapter covered the path parameters, which are part of the resource identity. This chapter covers the search parameters: the key-value pairs in the query string that configure the view. The TanStack Router’s search parameter handling is fundamentally different from the React Router’s. The React Router returns the raw URLSearchParams object, and the component parses the values. The TanStack Router has first-class support for the search param schemas. The validateSearch option in the route definition accepts a zod schema (or a custom validation function), parses the raw query string, and returns the typed search object. The Route.useSearch() hook returns the typed object. The Link and navigate calls are typed against the schema. The critical trap is the missing schema: a route without validateSearch has the unknown search type, and the useSearch returns the raw values.
Key point: The validateSearch option in the route definition is the schema for the search params. The zod library is the common choice, but any function that takes the raw input and returns the parsed object works. The Route.useSearch() returns the typed search object. The useSearch({ from: '/route' }) is the typed hook for the components outside the route file. The Link and navigate calls require the search prop that matches the schema. The search values are always serialized to the URL string; the schema handles the parsing.
Why search params require a specific mental model
The type problem. The React Router returns the URLSearchParams, and the values are strings. The TanStack Router returns the typed object when the validateSearch is defined. The page param is a number if the schema says z.number(). The sort param is an enum if the schema says z.enum([...]). The type safety extends to the Link and navigate calls: the search={{ page: 2 }} is typed, and the search={{ page: "2" }} is a compile error.
The validateSearch problem. The validateSearch is the function or the schema. The zod schema is the common choice: validateSearch: z.object({ page: z.number().default(1) }). The zod parses the raw string, applies the defaults, and returns the typed object. The custom function is the alternative: validateSearch: (search) => ({ page: Number(search.page) || 1 }). The custom function is the fallback for the schemas that the zod cannot express.
The serialization problem. The search object is serialized to the URL string by the router. The search: { page: 2 } becomes ?page=2. The search: { tags: ["a", "b"] } becomes ?tags=a&tags=b or ?tags=a,b depending on the search option. The search option in the route or the router defines the custom serializer. The zod schema does not serialize; the router does. The mismatch between the parse and the serialize is the source of the round-trip bugs.
The default problem. The zod default is the value when the param is absent. The z.object({ page: z.number().default(1) }) returns { page: 1 } for the empty query string. The URL does not contain the page=1. The default is the application-level default, not the URL-level default. The search={{ page: 1 }} in the Link is the explicit value; the search={{}} relies on the default.
The search middleware problem. The search.middlewares option allows the transformation of the search params before the validation. The stripSearchParams is the common middleware: it removes the default values from the URL. The search={{ page: 1 }} with the stripSearchParams becomes the empty query string. The URL is cleaner, and the default value is applied on the next read.
The trade-off. The typed validateSearch is stricter and requires the schema definition. The raw URLSearchParams is simpler but has the string values and the manual parsing. The typed approach is the recommended one because the schema is the single source of truth and the type errors are caught at the compile time.
a. The validateSearch and the Route.useSearch
The validateSearch is the schema for the route. The Route.useSearch() returns the typed object.
// src/routes/posts.tsx
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'
const searchSchema = z.object({
page: z.number().int().positive().default(1),
sort: z.enum(['name', 'date', 'views']).default('date'),
})
export const Route = createFileRoute('/posts')({
validateSearch: searchSchema,
component: PostsList,
})
function PostsList() {
const { page, sort } = Route.useSearch()
// page: number
// sort: "name" | "date" | "views"
return <p>Page {page}, sorted by {sort}</p>
}
The searchSchema is the zod object. The validateSearch uses it to parse the raw query string. The Route.useSearch() returns the typed { page, sort } object. The page is a number, and the sort is the enum.
The component can use the values without parsing. The page is already a number. The sort is already the enum. The type is inferred from the schema.
b. The typed Link and useNavigate
The search prop in the Link is typed against the schema.
import { Link } from '@tanstack/react-router'
<Link to="/posts" search={{ page: 2, sort: 'views' }}>
Page 2 sorted by views
</Link>
The search prop is the typed object. The page must be a number. The sort must be one of the enum values. The search={{ page: '2' }} is a compile error.
The useNavigate hook is typed the same way.
const navigate = useNavigate()
navigate({ to: '/posts', search: { page: 3, sort: 'name' } })
The search is the typed object. The navigation serializes it to the URL. The target route reads it with the Route.useSearch().
The partial search is the other pattern. The Link can pass the partial search if the route has the defaults for the missing values. The search={{ page: 2 }} uses the default sort. The type system allows the partial object if the schema has the defaults.
c. The search middleware and the custom serialization
The search.middlewares option allows the transformation of the search params. The stripSearchParams removes the default values from the URL.
import { stripSearchParams, createFileRoute } from '@tanstack/react-router'
const defaultSearch = { page: 1, sort: 'date' as const }
export const Route = createFileRoute('/posts')({
validateSearch: searchSchema,
search: {
middlewares: [stripSearchParams(defaultSearch)],
},
component: PostsList,
})
The stripSearchParams(defaultSearch) removes the values that match the defaults from the URL. The ?page=1&sort=date becomes the empty query string. The route still applies the defaults on the next read. The URL is cleaner.
The custom serialization is the alternative. The parseSearch and stringifySearch in the router options define how the search object is parsed from and serialized to the query string. The parseSearchWith(JSON.parse) is the custom parser for the JSON-encoded values. The stringifySearchWith(JSON.stringify) is the serializer.
import { createRouter, parseSearchWith, stringifySearchWith } from '@tanstack/react-router'
const router = createRouter({
routeTree,
parseSearch: parseSearchWith(JSON.parse),
stringifySearch: stringifySearchWith(JSON.stringify),
})
The custom parser and serializer are the advanced usage. The zod schema is the recommended approach for the standard cases.
Complete Example Session
# ============================================
# PART 1: DEFINE THE SCHEMA
# ============================================
import { z } from 'zod'
const searchSchema = z.object({
page: z.number().int().positive().default(1),
sort: z.enum(['name', 'date']).default('date'),
})
# ============================================
# PART 2: ADD TO THE ROUTE
# ============================================
export const Route = createFileRoute('/posts')({
validateSearch: searchSchema,
component: PostsList,
})
# ============================================
# PART 3: READ THE TYPED SEARCH
# ============================================
const { page, sort } = Route.useSearch()
// page: number, sort: "name" | "date"
# ============================================
# PART 4: TYPED LINK
# ============================================
<Link to="/posts" search={{ page: 2, sort: 'name' }}>Page 2</Link>
# ============================================
# PART 5: TYPED NAVIGATE
# ============================================
const navigate = useNavigate()
navigate({ to: '/posts', search: { page: 3, sort: 'date' } })
# ============================================
# PART 6: TYPE ERROR
# ============================================
<Link to="/posts" search={{ page: "2" }}>Invalid</Link>
// TypeScript error
# ============================================
# PART 7: STRIP DEFAULTS
# ============================================
search: {
middlewares: [stripSearchParams({ page: 1, sort: 'date' })],
}
# ============================================
# PART 8: CUSTOM PARSER
# ============================================
const router = createRouter({
routeTree,
parseSearch: parseSearchWith(JSON.parse),
stringifySearch: stringifySearchWith(JSON.stringify),
})
# ============================================
# PART 9: VERIFY
# ============================================
# /posts → { page: 1, sort: 'date' } (defaults)
# /posts?page=2&sort=name → { page: 2, sort: 'name' }
# /posts?page=invalid → validation error
The nine parts trace the search params: schema, route, typed read, typed link, typed navigate, type error, strip defaults, custom parser, and verification.
Quick Reference
Route Options
| Option | Purpose |
|---|---|
validateSearch | Schema or function |
search.middlewares | Transform params |
search | Per-route serializer |
Hooks
| Hook | Returns |
|---|---|
Route.useSearch() | Typed search object |
useSearch({ from }) | Typed search (external) |
Zod Schema
| Type | Parses |
|---|---|
z.string() | String |
z.number() | Number |
z.enum([...]) | Enum |
z.array(...) | Array |
z.object({...}) | Object |
.default(...) | Default value |
Link Props
| Prop | Type |
|---|---|
to | Typed path |
search | Typed search object |
Middleware
| Function | Purpose |
|---|---|
stripSearchParams(defaults) | Remove defaults from URL |
Best Practices
✅ Do This:
validateSearch: z.object({ page: z.number().default(1) })
<!-- Use zod schema for search --> # ✅
const { page } = Route.useSearch()
<!-- Use the typed hook --> # ✅
search: { middlewares: [stripSearchParams(defaults)] }
<!-- Strip the defaults from URL --> # ✅
❌ Don’t Do This:
<Link to="/posts" search={{ page: "2" }}>
# When schema expects number
<!-- Type error --> # ❌
const { page } = Route.useSearch()
array[page]
# When page is string
<!-- Type error --> # ❌
# Skip validateSearch
<!-- search type is unknown --> # ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Search undefined | No validateSearch | Add the schema |
| Type error on Link | Wrong value type | Match the schema |
| Defaults in URL | No stripSearchParams | Add the middleware |
| Invalid param crash | No validation | The schema throws |
| Parse error | Wrong serializer | Check parseSearch |
Real-World Examples
1. Basic Schema
validateSearch: z.object({ page: z.number().default(1) })
2. Enum Schema
validateSearch: z.object({ sort: z.enum(['name', 'date']).default('date') })
3. Typed Read
const { page } = Route.useSearch()
4. Typed Link
<Link to="/posts" search={{ page: 2 }}>Page 2</Link>
5. Typed Navigate
navigate({ to: '/posts', search: { page: 3 } })
6. Strip Defaults
search: { middlewares: [stripSearchParams({ page: 1 })] }
7. Array Param
validateSearch: z.object({ tags: z.array(z.string()).default([]) })
8. useSearch External
const { page } = useSearch({ from: '/posts' })
9. Custom Parse
parseSearch: parseSearchWith(JSON.parse)
10. Type Error
<Link to="/posts" search={{ page: "2" }}>Invalid</Link>
Visual
Typed Search Flow
┌──────────────────────────────────────────────┐
│ URL: /posts?page=2&sort=name │
└──────────────────┬───────────────────────────┘
│
│ validateSearch (zod)
▼
┌──────────────────────────────────────────────┐
│ Typed: { page: 2, sort: "name" } │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ Route.useSearch() → typed object │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ Component uses page as number, sort as enum │
└──────────────────────────────────────────────┘
Typed Link
┌──────────────────────────────────────────────┐
│ <Link to="/posts" search={{ page: 2 }}> │
│ → Valid (number) │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ <Link to="/posts" search={{ page: "2" }}> │
│ → TypeScript error │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Search schema | validateSearch |
| Common library | zod |
| Typed read | Route.useSearch() |
| External read | useSearch({ from }) |
| Typed link | <Link search={{ ... }}> |
| Typed navigate | navigate({ search: { ... } }) |
| Defaults | .default(...) in schema |
| Strip defaults | stripSearchParams(...) |
Key takeaways:
- The
validateSearchis the schema. It parses the raw query string and returns the typed object. Thezodlibrary is the common choice. The custom function is the alternative. - The
Route.useSearch()returns the typed object. Thepageis a number, and thesortis an enum. The component uses them directly. The type is inferred from the schema. - The
Linkandnavigatecalls are typed against the schema. Thesearch={{ page: 2 }}is valid. Thesearch={{ page: "2" }}is a compile error. The type safety prevents the runtime errors. - The
stripSearchParamsremoves the defaults from the URL. The?page=1&sort=datebecomes the empty query string. The route still applies the defaults on the next read. The URL is cleaner. - The
parseSearchandstringifySearchare the custom serializers. They are the advanced usage for the non-standard value types. Thezodschema is the recommended approach for the standard cases.
Remember: The validateSearch is the schema. The Route.useSearch is the typed read. The zod is the parser. The Link is the typed write. The stripSearchParams is the URL cleanup. The exam tests the validateSearch and the typed useSearch.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!