Compared with React Router's useSearchParams, how does TanStack Router's validateSearch keep a product list's page and filter search params typed and refactor-safe?
answer
- strings versus a validated object
- JSON-first parsing
- the schema is the single source
- links are checked against it
basics
~20 sReact Router's useSearchParams yields URLSearchParams strings that each reader parses. TanStack Router parses the query JSON-first and runs the route's validateSearch once, so Route.useSearch() is typed and every Link or navigate writing search is checked against that schema.
solid answer
~50 sWith React Router, `useSearchParams()` returns a `URLSearchParams` and a setter; `get('page')` yields `string | null`, so every component converts and defaults by hand, and renaming `page` in one place compiles fine everywhere else. TanStack Router makes search params part of the route definition. The router first parses the query **JSON-first** — numbers, booleans and arrays keep their types — then passes it to the route's `validateSearch`, which can be a function or a schema: Zod v4 directly, Zod v3 via `zodValidator` from `@tanstack/zod-adapter`, or any Standard Schema library. The output type flows into `Route.useSearch()`, into `loaderDeps`, and into every `<Link search>` and `navigate({ search })` that targets the route. Rename `filter` to `query` in the schema and every stale reader and writer fails to compile. Invalid input throws, reaching `onError` and the `errorComponent`, unless the schema supplies fallbacks.
code
tsx · 26 lines// src/routes/products.tsx
import { createFileRoute, Link } from "@tanstack/react-router";
import { z } from "zod"; // Zod v4: schema passed directly
const productSearch = z.object({
page: z.number().int().min(1).catch(1).default(1),
filter: z.string().catch("").default(""),
sort: z.enum(["newest", "price"]).catch("newest").default("newest"),
});
export const Route = createFileRoute("/products")({
validateSearch: productSearch,
component: ProductList,
});
function ProductList() {
const { page, filter, sort } = Route.useSearch(); // page: number, sort: "newest" | "price"
return (
<>
<p>{filter} sorted by {sort}</p>
<Link from={Route.fullPath} search={(prev) => ({ ...prev, page: page + 1 })}>
Next page
</Link>
</>
);
}go deeper
Know that TanStack Router validates search params with the route's validateSearch and gives components a typed object via Route.useSearch().
Contrast it concretely with React Router's URLSearchParams strings: JSON-first parsing, one schema, typed reads and typed writes on Link and navigate.
Design the schema for production: fallbacks for hostile input, defaults for optional links, loaderDeps for the cache, and error handling through onError and errorComponent.
Decide whether URL state should be a typed contract across the app, weighing the refactor safety against schema maintenance and the cost of migrating routers.
## The scenario A product list keeps its state in the URL: `/products?page=3&filter=shoes&sort=price`. Several components read it, links throughout the app write it, and the route's loader fetches with it. Six months later someone renames `filter` to `query`. What stops a stale link or reader from silently breaking? ## React Router: strings at the edge In React Router's declarative and data modes, search params are the browser's `URLSearchParams`: - `const [searchParams, setSearchParams] = useSearchParams()`, - `searchParams.get("page")` returns `string | null`, - `<Link to="/products?filter=shoes">` is just a string. Every reader must parse (`Number(...)`), default and validate on its own, and nothing ties the key `"filter"` in a link to the key read in the list component. A rename compiles cleanly and fails at runtime. ## TanStack Router: search params belong to the route TanStack Router treats search params as **typed, validated state owned by a route**: 1. **JSON-first parsing.** The default parser keeps the first level URL-compatible but preserves value types: `page=3` becomes the number `3`, `desc=true` the boolean `true`, and arrays or nested objects round-trip as JSON. 2. **`validateSearch`.** The route declares how raw input becomes trusted state. It receives the parsed object as `Record<string, unknown>` and returns the typed result — or it is a schema object: with Zod v4 the schema is passed directly, with Zod v3 through `zodValidator` from `@tanstack/zod-adapter`, and Standard Schema libraries such as Valibot or ArkType need no adapter. 3. **Typed reads.** `Route.useSearch()` returns the validated type; child routes inherit their parents' search types. 4. **Typed writes.** `<Link to="/products" search={{ page: 2 }}>` and `navigate({ search: (prev) => ({ ...prev, page: prev.page + 1 }) })` are checked against the target route's schema. ## Side by side | Concern | React Router `useSearchParams` | TanStack Router `validateSearch` | |---|---|---| | Value types | strings, or `null` | whatever the schema outputs | | Parsing and defaults | in every reader | once, in the route | | Writes checked | no — a string or `URLSearchParams` | yes — against the target route's type | | Rename a key | compiles; breaks at runtime | every stale reader and writer fails to compile | | Invalid input | whatever each reader does | `validateSearch` throws; `onError` and `errorComponent` handle it, or schema fallbacks repair it | ## Defaults, fallbacks and required params - A schema field with a **default** (`z.number().default(1)`) makes that param optional when *linking*, while it is always present when *reading*. That is why adapters matter: they expose separate input and output types. - To survive garbage such as `?page=abc` without showing an error screen, give fields fallbacks: `.catch()` in Zod v4, or the adapter's `fallback()` helper with Zod v3, which keeps the field's type where `.catch()` would widen it to `unknown`. - If `validateSearch` throws, the error carries `routerCode` `VALIDATE_SEARCH`, the route's `onError` runs, and its `errorComponent` renders instead of the page. ## Writing search params safely Writes come in two forms. An **object** replaces the search state for the target route: `search={{ page: 1, filter: "shoes" }}`. A **function** receives the previous validated state and returns the next: `search={(prev) => ({ ...prev, page: prev.page + 1 })}`, which keeps unrelated params such as `sort`. When a param should survive every navigation — a locale, a workspace id — the route's `search.middlewares` with `retainSearchParams(["locale"])` carries it forward, so individual links do not have to remember it. ## Feeding the loader Loaders do not receive `search` directly. Declare what they depend on with `loaderDeps: ({ search: { page, filter } }) => ({ page, filter })`; those values key the loader's cache and are type-checked from the same schema. ## Why this is the reserved interview angle "What does TanStack Router's type safety buy you over React Router?" is best answered with this concrete case: search params become a schema-validated, typed contract between the URL, the components that read it, the links that write it, and the loader that fetches with it — so a refactor that breaks the contract fails the build, not the user.
- Why do the docs recommend an adapter such as zodValidator for Zod v3 but not for Zod v4 or Valibot?A schema with defaults has different input and output types: optional when you link, always present when you read. Zod v3 needs `zodValidator` from `@tanstack/zod-adapter` to expose both correctly. Zod v4 and Standard Schema libraries such as Valibot or ArkType expose them natively, so the schema can be passed to `validateSearch` directly.
- What does the user see if validateSearch throws for ?page=abc?The error carries `routerCode` `VALIDATE_SEARCH`, the route's `onError` runs, and the route's `errorComponent` renders in place of its component. If you prefer to repair bad input silently, add fallbacks in the schema (`.catch()` in Zod v4, or the adapter's `fallback()` with Zod v3) so validation never throws.
saying these in an interview costs you the question
- TanStack Router hands components raw strings just like URLSearchParams
- validateSearch only runs when you call Route.useSearch()
- Search params with defaults must still be passed on every Link
- Loaders read search params directly from their arguments without loaderDeps
- Zod is the only schema library TanStack Router accepts
- Renaming a search key is caught only by end-to-end tests