skip to content

TanStack Router

TanStack Router is built around end-to-end type safety — typed routes, typed params, typed search params — with data loading integrated into the route tree. Interviewers ask what that type safety buys you compared with React Router.

on this pageshow

questions

5

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?

level: middleimportance: must knowfreq 45%

answer

  1. strings versus a validated object
  2. JSON-first parsing
  3. the schema is the single source
  4. links are checked against it

basics

~20 s

React 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 s

With 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
tsx
// 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

for a junior

Know that TanStack Router validates search params with the route's validateSearch and gives components a typed object via Route.useSearch().

for a middle

Contrast it concretely with React Router's URLSearchParams strings: JSON-first parsing, one schema, typed reads and typed writes on Link and navigate.

for a senior

Design the schema for production: fallbacks for hostile input, defaults for optional links, loaderDeps for the cache, and error handling through onError and errorComponent.

for a principal

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
open as a page

In TanStack Router, what is the difference between file-based routes generated by the bundler plugin and code-based routes built with createRoute?

level: juniorimportance: should knowfreq 38%

basics

~20 s

Both produce the same typed route tree. File-based routing lets the TanStack Router bundler plugin generate routeTree.gen.ts from files that call createFileRoute; code-based routing builds the tree by hand with createRootRoute, createRoute({ getParentRoute, path }) and addChildren.

open as a page

In TanStack Router, how do beforeLoad and router context let an _authenticated layout route gate its children using auth state that lives in a React hook?

level: middleimportance: should knowfreq 40%

basics

~20 s

Declare the context type with createRootRouteWithContext, pass the hook's value through <RouterProvider context={{ auth }}>, and read context.auth in the layout's beforeLoad, throwing redirect() when signed out. beforeLoad runs parent-first before loaders; call router.invalidate() when auth changes.

open as a page

A TanStack Router /products loader reads the page from location.search, and changing ?page=2 keeps showing page 1's data; why, and how do loaderDeps and staleTime fix it?

level: seniorimportance: should knowfreq 32%

basics

~20 s

TanStack Router caches loader results per route match, keyed by route, path params and loaderDeps — not the search string. A search-only change keeps the same match, so the loader is not re-run. Declaring loaderDeps for page gives each page its own entry.

open as a page