TanStack Router Type-Safe Search Params With Zod (2026)
June 4, 2026
In TanStack Router, URL search params are validated state, not loose strings. You attach a Zod schema to a route with validateSearch, and every read through useSearch() is fully typed, parsed, and defaulted. This guide builds a runnable products-listing route — pagination, sort, tag filters, an inStock toggle — with graceful fallbacks for garbage input, search middlewares that keep the URL clean, and a clear answer to a question that trips plenty of people up in 2026: does the Zod adapter work with Zod 4? (It does not — and the fix is one line.) Everything here type-checks against @tanstack/react-router@1.170.11 on the day of writing.
What you'll learn
- How to validate URL search params in TanStack Router with a Zod schema via
validateSearchandzodValidator. - How to make invalid or missing params degrade gracefully using
fallback()combined with.default(). - Why
@tanstack/zod-adapter@1.167.0refuses to install against Zod 4, and the two clean ways to fix it. - How to read fully typed params in a component with
Route.useSearch()and the standaloneuseSearch({ from, select })hook. - How to keep some params and drop others across navigation using
retainSearchParamsandstripSearchParamssearch middlewares. - How to update params type-safely with
Linkandnavigate()functional updaters, and feed them into a routeloader.
Prerequisites
- Node.js 22.12+ or 24 LTS. Vite 8 dropped Node 18 and requires Node 20.19+ / 22.12+1. Node 24 is the current Active LTS line2.
- A Vite + React 19 app (
react@19.2.7,react-dom@19.2.7). - TypeScript in strict mode with
moduleResolution: "bundler"(ornodenext). The TanStack Router docs recommend the strictest TypeScript settings to get the full type-safety experience3. - Familiarity with React and basic Zod schemas. No prior TanStack Router experience required.
This tutorial uses file-based routing (the recommended setup), but every API call is identical under code-based routing if you prefer it.
Step 1 — Install the packages
Pin exact versions so your build matches this guide. The two install lines below are deliberate — the Zod choice depends on which validation path you pick, covered in Step 4.
# Router + the file-based routing Vite plugin
npm install --save-exact @tanstack/react-router@1.170.11
npm install --save-exact -D @tanstack/router-plugin@1.168.14
# Validation: Zod 3 + the official adapter (Step 4 explains the Zod 4 alternative)
npm install --save-exact zod@3.25.76 @tanstack/zod-adapter@1.167.0
Expected result — npm ls shows the pinned versions with no peer-dependency warnings:
├── @tanstack/react-router@1.170.11
├── @tanstack/zod-adapter@1.167.0
├── zod@3.25.76
└── @tanstack/router-plugin@1.168.14
If you instead ran npm install zod@4, you would hit an ERESOLVE error. That is expected, not a mistake on your part — Step 4 explains exactly why and how to choose your path.
Step 2 — Wire up the Vite plugin for file-based routing
File-based routing generates a typed route tree from your src/routes directory. Add the plugin to vite.config.ts before @vitejs/plugin-react — the docs require this order, and the plugin raises an explicit error if @vitejs/plugin-react comes first4.
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { tanstackRouter } from '@tanstack/router-plugin/vite'
export default defineConfig({
plugins: [
tanstackRouter({ target: 'react', autoCodeSplitting: true }),
react(),
],
})
With its defaults, the plugin watches ./src/routes and writes a generated route tree to ./src/routeTree.gen.ts on every dev run and build4. You never edit that file by hand — it is the type-safe glue that makes every route path and every search schema known to the compiler. Run npm run dev once and confirm src/routeTree.gen.ts appears.
Step 3 — Register the router for end-to-end type safety
The router instance has to be registered with the type system so that Link, navigate, and useSearch know your route tree. Create the router and augment the Register interface5.
// src/router.ts
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
export const router = createRouter({ routeTree })
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
That declare module block is what upgrades every router API from "stringly typed" to "knows your exact routes." Skip it and the router has no route tree to infer from — useSearch results collapse to never instead of your schema type. Mount the router in src/main.tsx with <RouterProvider router={router} /> as usual.
Step 4 — Define the search schema (and the Zod 4 decision)
Here is the core of the tutorial. A products page needs a search query, a page number, a sort order, a list of tag filters, and an in-stock toggle. We want each one validated, defaulted, and resilient to bad input pasted into the URL bar.
Does TanStack Router's Zod adapter work with Zod 4? No. @tanstack/zod-adapter@1.167.0 declares peerDependencies: { "zod": "^3.23.8" }, so installing it next to zod@4.x fails with an ERESOLVE peer-conflict error6. You have two clean options, and both are first-class:
- Path A — Zod 3 + the adapter. Use
zod@3.25.76andzodValidator(). This unlocks the adapter'sfallback()helper, which catches per-field validation errors and substitutes a safe value instead of throwing. This is the path this guide uses. - Path B — Zod 4, no adapter. TanStack Router accepts any Standard Schema validator directly. Zod 4 schemas implement Standard Schema, so you pass the schema straight to
validateSearchwith no adapter and no peer conflict. You lose the adapter'sfallback()helper and lean on Zod's own.catch()instead. Step 9 shows this variant in full.
For Path A, create the schema with fallback() wrapping each field and .default() supplying the value when the param is absent:
// src/routes/products.tsx
import { createFileRoute } from '@tanstack/react-router'
import { zodValidator, fallback } from '@tanstack/zod-adapter'
import { z } from 'zod'
const SORT_KEYS = ['newest', 'price-asc', 'price-desc'] as const
export const productSearchSchema = z.object({
q: fallback(z.string(), '').default(''),
page: fallback(z.number().int().min(1), 1).default(1),
sort: fallback(z.enum(SORT_KEYS), 'newest').default('newest'),
tags: fallback(z.array(z.string()), []).default([]),
inStock: fallback(z.boolean(), false).default(false),
})
The fallback() and .default() pairing is doing two distinct jobs. .default(value) fills in the value when the param is missing from the URL. fallback(schema, value) catches the case where the param is present but invalid — for example ?page=-3 or ?sort=banana — and swaps in the safe value instead of throwing a validation error at the user5. Together they guarantee useSearch() always returns a complete, valid object, no matter what someone types into the address bar.
Notice the schema handles real types, not just strings: page is a number, inStock is a boolean, and tags is an array. TanStack Router serializes search with JSON.stringify/JSON.parse by default, so numbers, booleans, arrays, and nested objects round-trip through the URL without manual coercion7. A { page: 1, tags: ["sale"] } object becomes ?page=1&tags=["sale"] — with the brackets and quotes percent-encoded in the actual address bar (tags=%5B%22sale%22%5D) — and parses straight back into typed values.
Step 5 — Attach the schema to the route with validateSearch
Wrap the schema in zodValidator() and pass it to the route's validateSearch option. From this point on, the route's search is typed everywhere5.
// src/routes/products.tsx (continued)
export const Route = createFileRoute('/products')({
validateSearch: zodValidator(productSearchSchema),
component: ProductsPage,
})
zodValidator() adapts the Zod schema into the validator shape TanStack Router expects and threads Zod's inferred type into the route. The type that flows out the other side is exactly z.infer<typeof productSearchSchema> — { q: string; page: number; sort: 'newest' | 'price-asc' | 'price-desc'; tags: string[]; inStock: boolean }. There is no as cast anywhere, and no second place to declare the type.
Step 6 — Read typed params in the component
Route.useSearch() returns the validated object with full inference. No generics, no manual typing — the editor knows search.sort is a three-way union and search.page is a number.
// src/routes/products.tsx (continued)
function ProductsPage() {
const search = Route.useSearch()
const navigate = Route.useNavigate()
return (
<div>
<h1>Products</h1>
<p>
Page {search.page} · sorted by {search.sort}
{search.inStock ? ' · in stock only' : ''}
{search.tags.length > 0 ? ` · tags: ${search.tags.join(', ')}` : ''}
</p>
<button
onClick={() =>
navigate({ search: (prev) => ({ ...prev, page: prev.page + 1 }) })
}
>
Next page
</button>
</div>
)
}
Two things make this ergonomic. First, Route.useSearch() is bound to this route, so it needs no from argument. Second, navigate({ search: (prev) => ... }) takes a functional updater: prev is the current typed search, and the object you return is type-checked against the schema. Returning { ...prev, page: 'two' } would be a compile error, caught before it ships.
If you need the params from a component that is not the route component — a shared toolbar, say — use the standalone hook with an explicit from, and optionally a select function to subscribe to just one field and avoid needless re-renders:
import { useSearch } from '@tanstack/react-router'
function ResultsCount() {
// Only re-renders when `page` changes, not on every search update.
const page = useSearch({ from: '/products', select: (s) => s.page })
return <span>Viewing page {page}</span>
}
Step 7 — Build filter controls with typed Link
navigate() is for imperative updates from event handlers; for declarative links — sort buttons, a filter toggle, pagination — use Link with the same functional updater. The search prop receives the current typed params and returns the next ones, so a wrong key or value is a compile error. activeProps styles the link when its target matches the current location.
// src/routes/products.tsx — a sort + filter bar
import { Link } from '@tanstack/react-router'
const SORT_OPTIONS = [
{ value: 'newest', label: 'Newest' },
{ value: 'price-asc', label: 'Price ↑' },
{ value: 'price-desc', label: 'Price ↓' },
] as const
function ProductsToolbar() {
const search = Route.useSearch()
return (
<nav>
{SORT_OPTIONS.map((opt) => (
<Link
key={opt.value}
to="/products"
// Reset to page 1 whenever the sort changes — a common UX rule.
search={(prev) => ({ ...prev, sort: opt.value, page: 1 })}
activeProps={{ className: 'is-active' }}
>
{opt.label}
</Link>
))}
<Link
to="/products"
search={(prev) => ({ ...prev, inStock: !prev.inStock, page: 1 })}
>
{search.inStock ? 'Showing in-stock only' : 'Show in-stock only'}
</Link>
</nav>
)
}
Each link is a real, shareable URL — once Step 8's middlewares strip the default values, right-clicking "Copy link address" on the "Price ↑" button from a default state gives you a tidy /products?sort=price-asc, ready to paste into Slack or a bookmark. (Without those middlewares, the href carries the full validated search object, defaults and all.) Because the updater spreads prev first, every other active filter is preserved while only sort (and the reset page) changes. The as const on SORT_OPTIONS is what keeps opt.value narrowed to the literal union the schema expects, rather than a widened string.
Step 8 — Keep URLs clean with search middlewares
Two problems show up the moment real users navigate around. First, default values clutter the URL: a navigation that passes the full search object produces ?q=&page=1&sort=newest&tags=[]&inStock=false instead of a clean /products. Second, params silently reset: any navigation that supplies a fresh search object — navigate({ search: { page: 2 } }), say — re-validates the omitted params back to their defaults, wiping the user's q and sort in the process.
TanStack Router solves both with search middlewares declared on the route. stripSearchParams removes params that equal their default, and retainSearchParams carries the chosen params through same-route navigations that omit them5.
// src/routes/products.tsx — extend the Route definition
import {
createFileRoute,
retainSearchParams,
stripSearchParams,
} from '@tanstack/react-router'
export const Route = createFileRoute('/products')({
validateSearch: zodValidator(productSearchSchema),
search: {
middlewares: [
// Drop any param that currently equals its default value.
stripSearchParams({
q: '',
page: 1,
sort: 'newest',
tags: [],
inStock: false,
}),
// Keep q and sort when a same-route navigation omits them.
retainSearchParams(['q', 'sort']),
],
},
component: ProductsPage,
})
Order matters, and not in the direction you might guess: retainSearchParams re-applies its params after the rest of the pipeline runs, so if you list it before stripSearchParams, the retained keys survive the strip and a default-state URL keeps a dangling ?q=&sort=newest. With stripSearchParams listed first — as above, and verified against @tanstack/react-router@1.170.11 — a default-state visit lands on a bare /products, an active search like /products?q=boots&page=2 keeps exactly the params that carry meaning, and navigate({ search: { page: 2 } }) preserves the live q and sort instead of resetting them. The object you pass to stripSearchParams must match the schema's default values — that is what marks a param as "safe to omit."
One scope caveat: these middlewares belong to the /products route, so they run for navigations targeting /products. Navigating to a different route (/about) drops the products params entirely — that is by design, since other routes don't declare them. The official pattern for carrying a param across all routes is to declare it in a root-route search schema and attach retainSearchParams to the root route instead8.
Step 9 — The Zod 4 variant (no adapter)
If your app is already on Zod 4, skip the adapter entirely. Pass the Zod 4 schema straight to validateSearch — TanStack Router consumes it as a Standard Schema validator, and the inferred type comes through identically9. Use Zod's built-in .catch() in place of the adapter's fallback() for invalid-input recovery.
// src/routes/products.tsx — Zod 4, no @tanstack/zod-adapter
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod' // zod@4.x
const SORT_KEYS = ['newest', 'price-asc', 'price-desc'] as const
const productSearchSchema = z.object({
q: z.string().catch('').default(''),
page: z.number().int().min(1).catch(1).default(1),
sort: z.enum(SORT_KEYS).catch('newest').default('newest'),
tags: z.array(z.string()).catch([]).default([]),
inStock: z.boolean().catch(false).default(false),
})
export const Route = createFileRoute('/products')({
validateSearch: productSearchSchema, // schema passed directly
component: ProductsPage,
})
The trade-off is small and explicit. The adapter's fallback() is purpose-built for the "valid-but-out-of-range URL param" case and reads a little more clearly in a router schema, while Zod's built-in .catch() does the same job without any adapter — both variants resolve ?page=-5 to 1 and ?sort=banana to 'newest', verified against the same inputs. Both Route.useSearch(), the search middlewares, and the functional updaters from Steps 6 through 8 behave identically — only the schema construction differs.
Step 10 — Feed validated params into a loader
Validated search params are the right input for data fetching. Declare loaderDeps to pull the params a loader cares about, then read them as typed deps inside loader. The deps become part of the loader's cache key, so a navigation that changes only non-dep params won't trigger a refetch8.
// src/routes/products.tsx — add a loader to the Route
export const Route = createFileRoute('/products')({
validateSearch: zodValidator(productSearchSchema),
loaderDeps: ({ search }) => ({
q: search.q,
page: search.page,
sort: search.sort,
}),
loader: async ({ deps }) => {
// deps.q is string, deps.page is number, deps.sort is the union — all typed.
const params = new URLSearchParams({
q: deps.q,
page: String(deps.page),
sort: deps.sort,
})
const res = await fetch(`/api/products?${params.toString()}`)
return { items: (await res.json()) as Array<{ id: number; name: string }> }
},
component: ProductsPage,
})
Inside the component, read the result with Route.useLoaderData(), again fully typed. Because the loader keys off loaderDeps, changing inStock in the UI will not refetch unless you add it to the deps — you decide exactly which param changes are worth a round-trip.
Verification
Confirm the whole thing type-checks and behaves before you trust it. The fastest signal is the compiler: run a type check and expect zero errors.
npx tsc --noEmit
# (no output, exit code 0)
Then sanity-check the runtime behavior in the browser by editing the URL directly:
| You type | useSearch() returns | Why |
|---|---|---|
/products | { q:'', page:1, sort:'newest', tags:[], inStock:false } | .default() fills every missing param |
/products?page=2&sort=price-asc | { ..., page:2, sort:'price-asc' } | valid params parse to typed values |
/products?page=-5 | { ..., page:1 } | fallback() catches the invalid value |
/products?sort=banana | { ..., sort:'newest' } | fallback() rejects the bad enum |
That third and fourth row are the payoff: a hostile or stale URL never crashes the page or leaks an invalid value into your data layer. It quietly resolves to a safe state.
Troubleshooting
These are the real failures people hit, pulled from the docs and the issue tracker rather than invented.
npm installfails withERESOLVEpeer dependencyzod@"^3.23.8". You installed Zod 4 alongside@tanstack/zod-adapter, which currently peer-depends on Zod 36. Either pinzod@3.25.76(Path A) or drop the adapter and pass the Zod 4 schema directly (Path B, Step 9). Do not paper over it with--force— the adapter genuinely expects Zod 3 internals.useSearch()is typed asneverorunknown. TheRegisteraugmentation from Step 3 is missing or the route tree was not generated. Confirmsrc/routeTree.gen.tsexists (runnpm run dev) and thatdeclare module '@tanstack/react-router'is in a file the compiler includes.- Default params won't disappear from the URL. Two causes. Either the object passed to
stripSearchParamsdoesn't exactly match your schema's default values (e.g.page: 0when the default is1), orretainSearchParamsis listed beforestripSearchParams— the retained keys get re-applied after the strip, leaving?q=&sort=newestdangling. ListstripSearchParamsfirst (Step 8). - Numbers or booleans arrive as strings in the loader. You overrode serialization, or you are reading
window.locationinstead ofuseSearch()/loaderDeps. With the default JSON serializer,validateSearchhands you real numbers and booleans7 — always read through the router, never the raw query string. - A retained param won't carry to a different route.
retainSearchParamsis scoped to the route where it is declared — it preserves params for navigations targeting that route, not for navigations away from it. Navigating from/products?q=bootsto/aboutyields a bare/about. To carry a param across all routes, declare it in a root-route search schema and attach the middleware to the root route instead.
Next steps and further reading
You now have URL search params that are typed, validated, defaulted, and self-cleaning — the same pattern scales to dashboards, data tables, and any filterable view. From here:
- Move filter state for a data grid entirely into search params so every view is shareable and bookmarkable.
- Pair this with a typed data layer. The Astro Actions type-safe forms tutorial shows the same "schema is the single source of truth" idea on the server side, and the Next.js 16 Server Actions and optimistic UI tutorial applies it to mutations.
- For caching the data those params drive, the Next.js 16 Cache Components multi-tenant tutorial covers keying caches by request-derived values.
The official docs go deeper on custom serialization and validation patterns58.
Footnotes
-
Vite — Migration / requirements. Vite 8 requires Node.js 20.19+ or 22.12+. https://vite.dev/guide/migration ↩
-
Node.js Releases (LTS schedule). https://nodejs.org/en/about/previous-releases ↩
-
TanStack Router — Type Safety guide (recommended TypeScript configuration). https://tanstack.com/router/latest/docs/framework/react/guide/type-safety ↩
-
TanStack Router — Installation with Vite (router-plugin defaults: routesDirectory
./src/routes, generatedRouteTree./src/routeTree.gen.ts). https://tanstack.com/router/latest/docs/installation/with-vite ↩ ↩2 -
TanStack Router — Validate Search Parameters with Schemas. https://tanstack.com/router/latest/docs/framework/react/how-to/validate-search-params ↩ ↩2 ↩3 ↩4 ↩5
-
@tanstack/zod-adapter on npm —
peerDependencies: { zod: "^3.23.8" }. https://www.npmjs.com/package/@tanstack/zod-adapter ↩ ↩2 -
TanStack Router — Custom Search Param Serialization (default uses
JSON.stringify/JSON.parse). https://tanstack.com/router/latest/docs/framework/react/guide/custom-search-param-serialization ↩ ↩2 -
TanStack Router — Search Params guide. https://tanstack.com/router/latest/docs/guide/search-params ↩ ↩2 ↩3
-
Standard Schema — a common interface implemented by Zod, Valibot, and ArkType, consumed directly by TanStack Router's
validateSearch. https://standardschema.dev ↩