Search Params & Query State

Lesson 6 — typed, URL-driven UI state for filters, tabs, modals

Filters, sort order, pagination, "which modal is open" — these belong in the URL, not in useState. TanStack Router makes search params first-class and fully typed: declare a schema per route, read with a hook, write with navigation. Share the URL, refresh the page, hit back — the state survives all of it.

Why URL State, Not useState

State in useState dies on refresh, can't be shared, can't be deep-linked. URL state survives everything:

If the user would ever want to share or return to a UI state, it goes in the URL.

validateSearch — the schema

Every route can declare a validateSearch function. It receives raw parsed params and returns a typed object. The returned shape becomes the type of search everywhere downstream — components, child routes, loaders.

// routes/_app/(tabs)/feed/index.tsx
type SortOption = 'newest' | 'oldest' | 'top'

type FeedSearch = {
  page: number
  filter: string
  sort: SortOption
}

export const Route = createFileRoute('/_app/(tabs)/feed')({
  validateSearch: (raw: Record<string, unknown>): FeedSearch => ({
    page: Number(raw?.page ?? 1),
    filter: (raw.filter as string) || '',
    sort: (raw.sort as SortOption) || 'newest',
  }),
  component: FeedList,
})
⚡ Type flows everywhere The returned FeedSearch type becomes the type of search in this route's component and in every child route. One schema, type-safe everywhere below it in the tree.

With Zod (recommended)

Zod collapses validation + typing into one declaration. Pass the schema directly:

import { z } from 'zod'

const feedSearchSchema = z.object({
  page: z.number().catch(1),
  filter: z.string().catch(''),
  sort: z.enum(['newest', 'oldest', 'top']).catch('newest'),
})

export const Route = createFileRoute('/_app/(tabs)/feed')({
  // Pass the schema object directly — TanStack calls .parse for you
  validateSearch: feedSearchSchema,
})
⚠️ Use .catch(), not .default() Malformed search params are common (typos, old shared links). .default() throws on bad input — your user sees an error page. .catch() silently substitutes a fallback. For search params, always .catch(): never halt the UX for a malformed query string.

Reading Search Params

Three ways, same data:

// 1. Inside this route's component — typed hook on Route
function FeedList() {
  const { page, filter, sort } = Route.useSearch()
  //    ^? { page: number, filter: string, sort: SortOption }
  return <>...</>
}

// 2. Outside this route (e.g. a sidebar component) — useSearch with from
import { useSearch } from '@tanstack/react-router'
const { page } = useSearch({ from: '/_app/(tabs)/feed' })

// 3. Loosen typing when route is unknown
const search = useSearch({ strict: false })
//    ^? { page?: number, filter?: string, ... }

For deep components in a code-split tree, use getRouteApi('/...') instead of importing the Route object — avoids circular deps.

Writing Search Params

Writing is a navigation. Use navigate({ search }) or <Link search>. The search prop is a function of previous — never throw away params you didn't touch:

import { useNavigate } from '@tanstack/react-router'

function FeedControls() {
  const navigate = useNavigate({ from: Route.fullPath })

  return (
    <>
      {/* Pagination: only touches `page`, preserves filter + sort */}
      <button onClick={() => navigate({
        search: (prev) => ({ ...prev, page: prev.page + 1 })
      })}>
        Next page
      </button>

      {/* Via Link: same function form */}
      <Link
        to="/_app/(tabs)/feed"
        search={(prev) => ({ ...prev, sort: 'top' })}
      >
        Sort by top
      </Link>
    </>
  )
}
🧠 Always spread prev The search updater replaces the entire search object. Spreading ...prev first preserves untouched params. Forgetting it silently wipes filter when you change page.

Next.js → TanStack Quick Map

ConcernNext.jsTanStack
Read queryuseSearchParams() (untyped URLSearchParams)Route.useSearch() (typed)
Write queryrouter.push('?page=2') (string concat)navigate({ search: prev => ... }) (typed fn)
Schemamanual parsing, manual typesvalidateSearch + Zod
Type safetynone by defaultend-to-end, derived from schema

The shift: Next.js treats search params as a raw string you parse manually. TanStack treats them as a typed object you declare a schema for, then read and write with hooks — invalid values never reach your component.

Practice Exercise

The Feed screen needs filter UI: a text filter input, a sort dropdown (newest | top), and pagination. All state in the URL. Build:

  1. The validateSearch schema (Zod, all params with .catch() fallbacks)
  2. A component reading filter, sort, page via Route.useSearch()
  3. A filter input that writes filter and resets page to 1

Sketch mentally, then reveal.

Show solution
import { z } from 'zod'
import { createFileRoute, useNavigate } from '@tanstack/react-router'

const feedSearch = z.object({
  filter: z.string().catch(''),
  sort: z.enum(['newest', 'top']).catch('newest'),
  page: z.number().catch(1),
})

export const Route = createFileRoute('/_app/(tabs)/feed')({
  validateSearch: feedSearch,
  component: FeedList,
})

function FeedList() {
  const { filter, sort, page } = Route.useSearch()
  const navigate = useNavigate({ from: Route.fullPath })

  return (
    <div>
      <input
        value={filter}
        onChange={(e) => navigate({
          // Reset page to 1 when filter changes — fresh result set
          search: (prev) => ({ ...prev, filter: e.target.value, page: 1 })
        })}
      />
      <select
        value={sort}
        onChange={(e) => navigate({
          search: (prev) => ({ ...prev, sort: e.target.value })
        })}
      >
        <option value="newest">Newest</option>
        <option value="top">Top</option>
      </select>
      <span>Page {page}</span>
    </div>
  )
}

Why this works:

Quick Quiz

1. Where do you declare a route's search-param schema?

In the component body
In validateSearch
In the __root only
validateSearch is a route option. Its return type becomes the typed search for this route and all children.

2. Why prefer Zod's .catch() over .default()?

It runs faster
It avoids errors
It enables SSR
Malformed params are common. .default() throws on bad input (error page); .catch() substitutes a fallback silently. Never halt UX for a bad query string.

3. How do you read search params in the route component?

props.search
Route.useSearch()
useQuery hook
Route.useSearch() returns the typed, validated search object. Outside the route, use useSearch({ from }).

4. Writing search params is done via?

setSearch() setter
navigate({ search })
window.history
Writing is a navigation. navigate({ search: prev => ... }) or <Link search>. No separate setter — the URL is the store.

5. Why spread prev in the search updater?

For performance
To preserve others
To trigger re-render
The updater replaces the entire search object. Without ...prev, untouched params (like filter) get silently wiped when you change page.

6. The schema's return type is available where?

This route only
This and children
The __root only
The validated search type flows down the tree — this route's component and all descendant routes see the typed search.

7. Outside the route, which hook reads typed search?

useRouteSearch()
useSearch({ from })
useParams()
useSearch({ from: '/...' }) reads any route's typed search from anywhere. For code-split trees, getRouteApi avoids circular imports.

8. What does useSearch({ strict: false }) return?

Untyped any object
Optional typed fields
Only the root search
Loosens typing — each field becomes T | undefined. Useful when the origin route is unknown at write time.

9. Next.js useSearchParams() returns?

A typed object
URLSearchParams
A Zod schema
Next.js returns raw URLSearchParams — untyped, manual parsing. TanStack returns a typed object derived from validateSearch.

10. Filter change should also reset which param?

The sort order
The page number
The route path
Old page numbers are meaningless for a new filter — reset page to 1. Spread ...prev, override filter and page together.
← Lesson 5: Navigation Lesson 7: Loaders →
🎓 Stuck on something?
Ask your teacher — when to use URL state vs useState, how to share a filter state across routes, or how to pipe search params into a loader (coming in Lesson 7).

Primary source: TanStack Router — Search Params (Validating, Reading, and Writing sections). Read after the quiz to see the full validateSearch + Zod integration.