Open any product page with filters, change the URL to ?page=abc&sort=cheapest, and press Enter.
Most apps do one of three things. They crash, they show an empty list, or they quietly treat "abc" as a page number and send it to the API. All three happen for the same reason: the app treats the URL as trusted data, when it's really user input.
This post shows how TanStack Start handles that. By the end you'll have a filter page where:
- bad or missing values fall back to defaults instead of breaking the page,
- your component reads
pageas anumberandsortas one of three strings, notstring | undefined, - a
<Link>that passes the wrong type fails at compile time, - the loader refetches only when the filters it depends on change.
It's for React devs who know the basics of TanStack Start (file routes, loaders). If you don't, our free TanStack Start course on YouTube covers them.
Why the URL is the right place for filters
Filters, sort order and pagination are state. You could keep them in useState, but then:
- refreshing the page resets them,
- sharing a link loses them,
- the back button doesn't undo a filter change.
Put them in the URL and you get all three for free. The cost is that URL values arrive as strings, from anyone, in any shape. So you need validation, and that's what TanStack Router (the router inside TanStack Start) is built around.
Step 1: describe the search params with a schema
Each route can declare a validateSearch function. Give it a Zod schema and the router runs it every time the URL changes.
// src/routes/products.tsx
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'
const productSearch = z.object({
q: z.string().catch(''),
page: z.number().int().min(1).catch(1),
sort: z.enum(['newest', 'price-asc', 'price-desc']).catch('newest'),
})
export const Route = createFileRoute('/products')({
validateSearch: productSearch,
})
Two details matter here.
.catch() instead of .default(). .default() only kicks in when a value is missing. .catch() also covers values that are wrong. With .catch(), ?page=abc becomes page: 1 instead of an error. Here's what the schema returns, checked with Zod 4.6:
productSearch.parse({ page: 'abc', sort: 'cheapest' })
// { q: '', page: 1, sort: 'newest' }
productSearch.parse({ q: 'mug', page: 3, sort: 'price-asc' })
// { q: 'mug', page: 3, sort: 'price-asc' }
productSearch.parse({ page: 0 })
// { q: '', page: 1, sort: 'newest' }
No adapter with Zod 4. The TanStack Router docs say Zod 4 schemas can be passed to validateSearch directly. If you're still on Zod 3, wrap the schema with zodValidator from @tanstack/zod-adapter and use its fallback() helper, because Zod 3's .catch() loses the output types.
If you'd rather show an error than fall back, leave out .catch(). A failed validation renders the route's errorComponent.
Step 2: read typed values in the component
function Products() {
const { q, page, sort } = Route.useSearch()
// q: string, page: number, sort: 'newest' | 'price-asc' | 'price-desc'
}
No parseInt, no ?? 1, no casting. The types come from the schema, so if you add a field to the schema, every place that reads it knows.
Step 3: update the URL without losing other filters
You change search params the same way you navigate: with <Link> or navigate(). Both accept a function that receives the current params, which is the easiest way to change one filter and keep the rest.
import { Link, useNavigate } from '@tanstack/react-router'
function Products() {
const { q, page, sort } = Route.useSearch()
const navigate = useNavigate({ from: Route.fullPath })
return (
<div>
<input
value={q}
onChange={(e) =>
navigate({
search: (prev) => ({ ...prev, q: e.target.value, page: 1 }),
replace: true,
})
}
/>
<select
value={sort}
onChange={(e) =>
navigate({
search: (prev) => ({ ...prev, sort: e.target.value as typeof sort }),
})
}
>
<option value="newest">Newest</option>
<option value="price-asc">Price: low to high</option>
<option value="price-desc">Price: high to low</option>
</select>
<Link from={Route.fullPath} search={(prev) => ({ ...prev, page: prev.page + 1 })}>
Next page
</Link>
</div>
)
}
A few choices worth explaining:
-
page: 1when the query changes. Otherwise someone on page 5 types a new search and lands on an empty page 5. -
replace: trueon the text input. Without it, every keystroke adds a history entry, and the back button walks through "m", "mu", "mug". Sort changes are deliberate, so they keep normal history. -
prev.page + 1is typed.prev.pageis anumber, so this is arithmetic, not string concatenation ("1" + 1would give you page"11").
The part that pays off later
Because the route owns its search schema, the router knows what /products accepts. A link anywhere in the app that passes the wrong type doesn't compile:
// Type error: page must be a number
<Link to="/products" search={{ q: '', page: '2', sort: 'newest' }}>
Products
</Link>
We checked this with tsc (TypeScript 7.0, TanStack Router 1.170): the line above fails, and the valid version passes. In a large app, this is the difference between finding a broken filter link in code review and finding it in a bug report.
Step 4: let the loader depend on the filters
Now fetch the data. A loader can read search params, but you should declare which ones it depends on with loaderDeps. The router uses those deps as the cache key, so the loader reruns when they change and serves cached data when they don't.
export const Route = createFileRoute('/products')({
validateSearch: productSearch,
loaderDeps: ({ search: { q, page, sort } }) => ({ q, page, sort }),
loader: ({ deps }) => fetchProducts(deps),
component: Products,
})
function Products() {
const products = Route.useLoaderData()
// ...
}
Only return the params the loader actually uses. If you return the whole search object and later add a UI-only param (say, view: 'grid' | 'list'), switching views would refetch data for no reason.
In TanStack Start, fetchProducts would usually be a server function (createServerFn), so the query runs on the server and the same typed object crosses the network.
What you have now
- One schema describes what
/productsaccepts. - Bad URLs fall back to defaults instead of breaking the page.
- Components read typed values, with no parsing code.
- Links and navigations are checked against the schema at compile time.
- The loader refetches only when its inputs change.
Limits and caveats
-
Validation isn't authorisation. A valid
pagenumber says nothing about whether this user may see that data. Check permissions on the server. - Keep URLs small. Search params are great for filters and IDs. Large objects belong in your database, not the address bar.
-
Sorting on the server. If
sortmaps to a SQLORDER BY, keep the enum. Never pass a raw string from the URL into a query.
Going further
Type-safe search params are one lesson in our TanStack Start Pro course. It also covers streaming SSR, middleware and webhooks, router cache vs Query cache, testing and production deploys. The course is in early access, and new lessons land in your account as they're recorded: TanStack Start Pro.
How do you handle filter state today: URL, local state, or a store?
Sources: TanStack Router: Search params guide · TanStack Router: Validate search params · Zod API docs ("Catch" section) · TanStack Router: Data loading (loaderDeps)
Top comments (0)