| | |

TanStack Router 08 ⚛️ Search Params — Zod validation & parsing

⏱️ Estimated read: 10 min | |

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

OptionPurpose
validateSearchSchema or function
search.middlewaresTransform params
searchPer-route serializer

Hooks

HookReturns
Route.useSearch()Typed search object
useSearch({ from })Typed search (external)

Zod Schema

TypeParses
z.string()String
z.number()Number
z.enum([...])Enum
z.array(...)Array
z.object({...})Object
.default(...)Default value

Link Props

PropType
toTyped path
searchTyped search object

Middleware

FunctionPurpose
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

PitfallWhy It HappensFix
Search undefinedNo validateSearchAdd the schema
Type error on LinkWrong value typeMatch the schema
Defaults in URLNo stripSearchParamsAdd the middleware
Invalid param crashNo validationThe schema throws
Parse errorWrong serializerCheck 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

ItemValue
Search schemavalidateSearch
Common libraryzod
Typed readRoute.useSearch()
External readuseSearch({ from })
Typed link<Link search={{ ... }}>
Typed navigatenavigate({ search: { ... } })
Defaults.default(...) in schema
Strip defaultsstripSearchParams(...)

Key takeaways:

  • The validateSearch is the schema. It parses the raw query string and returns the typed object. The zod library is the common choice. The custom function is the alternative.
  • The Route.useSearch() returns the typed object. The page is a number, and the sort is an enum. The component uses them directly. The type is inferred from the schema.
  • The Link and navigate calls are typed against the schema. The search={{ page: 2 }} is valid. The search={{ page: "2" }} is a compile error. The type safety prevents the runtime errors.
  • The stripSearchParams removes 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 parseSearch and stringifySearch are the custom serializers. They are the advanced usage for the non-standard value types. The zod schema 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.

Was this helpful?


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!