Similar to how TanStack Query made handling server-state in your React applications a breeze, TanStack Router aims to unlock the power of URL search params in your applications.
URLSearchParams
?We get it, you've been hearing a lot of "use the platform" lately and for the most part, we agree. However, we also believe it's important to recognize where the platform falls short for more advanced use-cases and URLSearchParams
is one of these circumstances.
Traditional Search Param APIs usually assume a few things:
URLSearchParams
is good enough (Spoiler alert: it's not, it sucks)Reality is very different from these assumptions though.
URLSearchParams
.You've probably seen search params like ?page=3
or ?filter-name=tanner
in the URL. There is no question that this is truly a form of global state living inside of the URL. It's valuable to store specific pieces of state in the URL because:
To achieve the above, the first step built in to TanStack Router is a powerful search param parser that automatically converts the search string of your URL to structured JSON. This means that you can store any JSON-serializable data structure in your search params and it will be parsed and serialized as JSON. This is a huge improvement over URLSearchParams
which has limited support for array-like structures and nested data.
For example, navigating to the following route:
const link = ( <Link to="/shop" search={{ pageIndex: 3, includeCategories: ['electronics', 'gifts'], sortBy: 'price', desc: true, }} />)
Will result in the following URL:
/shop?pageIndex=3&includeCategories=%5B%22electronics%22%2C%22gifts%22%5D&sortBy=price&desc=true
When this URL is parsed, the search params will be accurately converted back to the following JSON:
{ "pageIndex": 3, "includeCategories": ["electronics", "gifts"], "sortBy": "price", "desc": true}
If you noticed, there are a few things going on here:
URLSearchParams
.🧠It's common for other tools to assume that search params are always flat and string-based which is why we've chosen to keep things URLSearchParam compliant at the first level. This ultimately means that even though TanStack Router is managing your nested search params as JSON, other tools will still be able to write to the URL and read first-level params normally.
Despite TanStack Router being able to parse search params into reliable JSON, they ultimately still came from a user-facing raw-text input. Similar to other serialization boundaries, this means that before you consume search params, they should be validated into a format that your application can trust and rely on.
TanStack Router provides convenient APIs for validating and typing search params. This all starts with the Route
's validateSearch
option:
interface ProductSearch { page: number filter: string sort: 'newest' | 'oldest' | 'price'}
const allProductsRoute = new Route({ getParentRoute: () => shopRoute, path: 'products', validateSearch: (search: Record<string, unknown>): ProductSearch => { // validate and parse the search params into a typed state return { page: Number(search?.page ?? 1), filter: search.filter || '', sort: search.sort || 'newest', } },})
In the above example, we're validating the search params of the allProductsRoute
and returning a typed ProductSearch
object. This typed object is then made available to this route's other options and any child routes, too!
The validateSearch
option is a function that is provided the JSON parsed (but non-validated) search params as a Record<string, unknown>
and returns a typed object of your choice. It's usually best to provide sensible fallbacks for malformed or unexpected search params so your users experience stays non-interrupted.
Here's an example:
interface ProductSearch { page: number filter: string sort: 'newest' | 'oldest' | 'price'}
const allProductsRoute = new Route({ getParentRoute: () => shopRoute, path: 'products', validateSearch: (search: Record<string, unknown>): ProductSearch => { // validate and parse the search params into a typed state return { page: Number(search?.page ?? 1), filter: search.filter || '', sort: search.sort || 'newest', } },})
Here's an example using the Zod library (but feel free to use any validation library you want) to both validate and type the search params in a single step:
import { z } from 'zod'
const productSearchSchema = z.object({ page: z.number().catch(1), filter: z.string().catch(''), sort: z.enum(['newest', 'oldest', 'price']).catch('newest'),})
type ProductSearch = z.infer<typeof productSearchSchema>
const allProductsRoute = new Route({ getParentRoute: () => shopRoute, path: 'products', validateSearch: (search) => productSearchSchema.parse(search),})
Because validateSearch
also accepts an object with the parse
property, this can be shortened to:
validateSearch: productSearchSchema
In the above example, we used Zod's .catch()
modifier instead of .default()
to avoid showing an error to the user because we firmly believe that if a search parameter is malformed, you probably don't want to halt the user's experience through the app to show a big fat error message. That said, there may be times that you do want to show an error message. In that case, you can use .default()
instead of .catch()
.
The underlying mechanics why this works relies on the validateSearch
function throwing an error. If an error is thrown, the route's onValidateSearchError
and onError
options will both be triggered and the errorComponent
will be rendered instead of the route's component
where you can handle the search param error however you'd like.
Once your search params have been validated and typed, you're finally ready to start reading and writing to them. There are a few ways to do this in TanStack Router, so let's check them out.
Thanks to TypeScript, you can access your route's validated search params in all sibling route options except beforeLoad
:
const productSearchSchema = z.object({ page: z.number().catch(1), filter: z.string().catch(''), sort: z.enum(['newest', 'oldest', 'price']).catch('newest'),})
type ProductSearch = z.infer<typeof productSearchSchema>
const allProductsRoute = new Route({ getParentRoute: () => shopRoute, path: 'products', validateSearch: productSearchSchema, loader: ({ search }) => { search // ^? ProductSearch ✅ },})
The search parameters and types of parents are merged as you go down the route tree, so child routes also have access to their parent's search params:
const productSearchSchema = z.object({ page: z.number().catch(1), filter: z.string().catch(''), sort: z.enum(['newest', 'oldest', 'price']).catch('newest'),})
type ProductSearch = z.infer<typeof productSearchSchema>
const allProductsRoute = new Route({ getParentRoute: () => shopRoute, path: 'products', validateSearch: productSearchSchema,})
const productRoute = new Route({ getParentRoute: () => allProductsRoute, path: ':productId', loader: ({ search }) => { search // ^? ProductSearch ✅ },})
You can access your route's validated search params in your route's component
via the useSearch
hook (or your framework's equivalent). By passing the from
id/path of your origin route, you'll get even better type safety:
const allProductsRoute = new Route({ getParentRoute: () => shopRoute, path: 'products', validateSearch: productSearchSchema,})
const ProductList = () => { const { page, filter, sort } = useSearch({ from: allProductsRoute.id })
return <div>...</div>}
You can access your route's validated search params anywhere in your app using:
router.state.currentLocation.state
router.state.pendingLocation.state
router.state.latestLocation.state
Each one represent different states of the router. currentLocation
is the current location of the router, pendingLocation
is the location that the router is transitioning to, and latestLocation
is most up-to-date representation of the location that the router has synced from the URL.
Now that you've learned how to read your route's search params, you'll be happy to know that you've already seen the primary APIs to modify and update them. Let's remind ourselves a bit
<Link search />
The best way to update search params is to use the search
prop on the <Link />
component. Remember, if a to
prop is omitted, will update the search for the current page. Here's an example:
const allProductsRoute = new Route({ getParentRoute: () => shopRoute, path: 'products', validateSearch: productSearchSchema,})
const ProductList = () => { return ( <div> <Link from={allProductsRoute.id} search={(prev) => ({ page: prev.page + 1 })} > Next Page </Link> </div> )}
useNavigate(), navigate({ search })
The navigate
function also accepts a search
option that works the same way as the search
prop on <Link />
:
const allProductsRoute = new Route({ getParentRoute: () => shopRoute, path: 'products', validateSearch: productSearchSchema,})
const ProductList = () => { const navigate = useNavigate({ from: allProductsRoute.id })
return ( <div> <button onClick={() => { navigate({ search: (prev) => ({ page: prev.page + 1 }), }) }} > Next Page </button> </div> )}
router.navigate({ search })
The router.navigate
function works exactly the same was as the useNavigate
/navigate
hook/function above.
<Navigate search />
The <Navigate search />
component works exactly the same was as the useNavigate
/navigate
hook/function above, but accepts its options as props instead of a function argument.