DEV Community

JavaScript Mastery
JavaScript Mastery

Posted on

Your URL is user input: type-safe filters in TanStack Start

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 page as a number and sort as one of three strings, not string | 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,
})
Enter fullscreen mode Exit fullscreen mode

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' }
Enter fullscreen mode Exit fullscreen mode

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'
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

A few choices worth explaining:

  • page: 1 when the query changes. Otherwise someone on page 5 types a new search and lands on an empty page 5.
  • replace: true on 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 + 1 is typed. prev.page is a number, so this is arithmetic, not string concatenation ("1" + 1 would 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>
Enter fullscreen mode Exit fullscreen mode

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()
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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 /products accepts.
  • 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 page number 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 sort maps to a SQL ORDER 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)