---
title: TanStack Router Type-Safe Search Params With Zod (2026)
description: 'Validate URL search params in TanStack Router with Zod: defaults, fallbacks, search middlewares, and the Zod 4 adapter gotcha. Runnable 2026 guide.'
url: https://nerdleveltech.com/tanstack-router-type-safe-search-params-zod-tutorial
language: en
date: '2026-06-04'
category: web-frontend
tags:
  - tanstack-router
  - zod
  - search-params
  - type-safe-routing
  - react
  - typescript
  - vite
  - tutorial
  - web-frontend
markdown_url: https://nerdleveltech.com/tanstack-router-type-safe-search-params-zod-tutorial.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/tanstack-router-type-safe-search-params-zod-tutorial'
terms: https://nerdleveltech.com/llms.txt
---

# TanStack Router Type-Safe Search Params With Zod (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 `validateSearch` and `zodValidator`.
- How to make invalid or missing params degrade gracefully using `fallback()` combined with `.default()`.
- Why `@tanstack/zod-adapter@1.167.0` refuses 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 standalone `useSearch({ from, select })` hook.
- How to keep some params and drop others across navigation using `retainSearchParams` and `stripSearchParams` search middlewares.
- How to update params type-safely with `Link` and `navigate()` functional updaters, and feed them into a route `loader`.

## Prerequisites

- **Node.js 22.12+ or 24 LTS.** Vite 8 dropped Node 18 and requires Node 20.19+ / 22.12+[^vite-node]. Node 24 is the current Active LTS line[^node-lts].
- **A Vite + React 19 app** (`react@19.2.7`, `react-dom@19.2.7`).
- **TypeScript in strict mode** with `moduleResolution: "bundler"` (or `nodenext`). The TanStack Router docs recommend the strictest TypeScript settings to get the full type-safety experience[^tsr-types].
- 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.

```bash
# 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:

```text
├── @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 first[^tsr-vite].

```ts
// 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 build[^tsr-vite]. 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` interface[^tsr-validate].

```ts
// 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 error[^zod-adapter-npm]. You have two clean options, and both are first-class:

- **Path A — Zod 3 + the adapter.** Use `zod@3.25.76` and `zodValidator()`. This unlocks the adapter's `fallback()` 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](https://standardschema.dev) validator directly. Zod 4 schemas implement Standard Schema, so you pass the schema straight to `validateSearch` with no adapter and no peer conflict. You lose the adapter's `fallback()` 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:

```ts
// 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 user[^tsr-validate]. 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 coercion[^tsr-serialize]. 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 everywhere[^tsr-validate].

```ts
// 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.

```tsx
// 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:

```tsx
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.

```tsx
// 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 them[^tsr-validate].

```ts
// 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 instead[^tsr-search-guide].

## 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 identically[^standard-schema]. Use Zod's built-in `.catch()` in place of the adapter's `fallback()` for invalid-input recovery.

```ts
// 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 refetch[^tsr-search-guide].

```tsx
// 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.

```bash
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 install` fails with `ERESOLVE` peer dependency `zod@"^3.23.8"`.** You installed Zod 4 alongside `@tanstack/zod-adapter`, which currently peer-depends on Zod 3[^zod-adapter-npm]. Either pin `zod@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 as `never` or `unknown`.** The `Register` augmentation from Step 3 is missing or the route tree was not generated. Confirm `src/routeTree.gen.ts` exists (run `npm run dev`) and that `declare 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 `stripSearchParams` doesn't exactly match your schema's default values (e.g. `page: 0` when the default is `1`), or `retainSearchParams` is listed before `stripSearchParams` — the retained keys get re-applied after the strip, leaving `?q=&sort=newest` dangling. List `stripSearchParams` first (Step 8).
- **Numbers or booleans arrive as strings in the loader.** You overrode serialization, or you are reading `window.location` instead of `useSearch()`/`loaderDeps`. With the default JSON serializer, `validateSearch` hands you real numbers and booleans[^tsr-serialize] — always read through the router, never the raw query string.
- **A retained param won't carry to a different route.** `retainSearchParams` is 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=boots` to `/about` yields 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](https://nerdleveltech.com/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](https://nerdleveltech.com/nextjs-16-server-actions-react-19-optimistic-ui-tutorial) applies it to mutations.
- For caching the data those params drive, the [Next.js 16 Cache Components multi-tenant tutorial](https://nerdleveltech.com/nextjs-16-cache-components-multi-tenant-cache-keys-tutorial) covers keying caches by request-derived values.

The official docs go deeper on custom serialization and validation patterns[^tsr-validate][^tsr-search-guide].

## Related reads

- [React Activity Component Tutorial: Stateful Tabs (2026)](https://nerdleveltech.com/react-activity-component-tutorial)

[^vite-node]: Vite — Migration / requirements. Vite 8 requires Node.js 20.19+ or 22.12+. https://vite.dev/guide/migration
[^node-lts]: Node.js Releases (LTS schedule). https://nodejs.org/en/about/previous-releases
[^tsr-validate]: TanStack Router — Validate Search Parameters with Schemas. https://tanstack.com/router/latest/docs/framework/react/how-to/validate-search-params
[^tsr-types]: TanStack Router — Type Safety guide (recommended TypeScript configuration). https://tanstack.com/router/latest/docs/framework/react/guide/type-safety
[^tsr-vite]: 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
[^zod-adapter-npm]: @tanstack/zod-adapter on npm — `peerDependencies: { zod: "^3.23.8" }`. https://www.npmjs.com/package/@tanstack/zod-adapter
[^tsr-serialize]: 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
[^standard-schema]: Standard Schema — a common interface implemented by Zod, Valibot, and ArkType, consumed directly by TanStack Router's `validateSearch`. https://standardschema.dev
[^tsr-search-guide]: TanStack Router — Search Params guide. https://tanstack.com/router/latest/docs/guide/search-params
