TanStack Router Type-Safe Search Params With Zod (2026)

June 4, 2026

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+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" (or nodenext). 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.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 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:

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

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 typeuseSearch() returnsWhy
/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 36. 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 booleans7 — 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:

The official docs go deeper on custom serialization and validation patterns58.

Footnotes

  1. Vite — Migration / requirements. Vite 8 requires Node.js 20.19+ or 22.12+. https://vite.dev/guide/migration

  2. Node.js Releases (LTS schedule). https://nodejs.org/en/about/previous-releases

  3. TanStack Router — Type Safety guide (recommended TypeScript configuration). https://tanstack.com/router/latest/docs/framework/react/guide/type-safety

  4. 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

  5. TanStack Router — Validate Search Parameters with Schemas. https://tanstack.com/router/latest/docs/framework/react/how-to/validate-search-params 2 3 4 5

  6. @tanstack/zod-adapter on npm — peerDependencies: { zod: "^3.23.8" }. https://www.npmjs.com/package/@tanstack/zod-adapter 2

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

  8. TanStack Router — Search Params guide. https://tanstack.com/router/latest/docs/guide/search-params 2 3

  9. Standard Schema — a common interface implemented by Zod, Valibot, and ArkType, consumed directly by TanStack Router's validateSearch. https://standardschema.dev


FREE WEEKLY NEWSLETTER

Stay on the Nerd Track

One email per week — courses, deep dives, tools, and AI experiments.

No spam. Unsubscribe anytime.