Search Params

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.

Why not just use 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 we believe URLSearchParams is one of these circumstances.

Traditional Search Param APIs usually assume a few things:

  • Search params are always strings
  • They are mostly flat
  • Serializing and deserializing using URLSearchParams is good enough (Spoiler alert: it's not.)
  • Search params modifications are tightly coupled with the URL's pathname and must be updated together, even if the pathname is not changing.

Reality is very different from these assumptions though.

  • Search params represent application state, so inevitably, we will expect them to have the same DX associated with other state managers. This means having the capability of distinguishing between primitive value types and efficiently storing and manipulating complex data structures like nested arrays and objects.
  • There are many ways to serialize and deserialize state with different tradeoffs. You should be able to choose the best one for your application or at the very least get a better default than URLSearchParams.
  • Immutability & Structural Sharing. Every time you stringify and parse url search params, referential integrity and object identity is lost because each new parse creates a brand new data structure with a unique memory reference. If not properly managed over its lifetime, this constant serialization and parsing can result in unexpected and undesirable performance issues, especially in frameworks like React that choose to track reactivity via immutability or in Solid that normally relies on reconciliation to detect changes from deserialized data sources.
  • Search params, while an important part of the URL, do frequently change independently of the URL's pathname. For example, a user may want to change the page number of a paginated list without touching the URL's pathname.

Search Params, the "OG" State Manager

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:

  • Users should be able to:
    • Cmd/Ctrl + Click to open a link in a new tab and reliably see the state they expected
    • Bookmark and share links from your application with others with assurances that they will see exactly the state as when the link was copied.
    • Refresh your app or navigate back and forth between pages without losing their state
  • Developers should be able to easily:
    • Add, remove or modify state in the URL with the same great DX as other state managers
    • Easily validate search params coming from the URL in a format and type that is safe for their application to consume
    • Read and write to search params without having to worry about the underlying serialization format

JSON-first Search Params

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:

tsx
const link = (
  <Link
    to="/shop"
    search={{
      pageIndex: 3,
      includeCategories: ['electronics', 'gifts'],
      sortBy: 'price',
      desc: true,
    }}
  />
)
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
/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:

json
{
  "pageIndex": 3,
  "includeCategories": ["electronics", "gifts"],
  "sortBy": "price",
  "desc": true
}
{
  "pageIndex": 3,
  "includeCategories": ["electronics", "gifts"],
  "sortBy": "price",
  "desc": true
}

If you noticed, there are a few things going on here:

  • The first level of the search params is flat and string based, just like URLSearchParams.
  • First level values that are not strings are accurately preserved as actual numbers and booleans.
  • Nested data structures are automatically converted to URL-safe JSON strings

🧠 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.

Validating and Typing Search Params

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.

Enter Validation + TypeScript!

TanStack Router provides convenient APIs for validating and typing search params. This all starts with the Route's validateSearch option:

tsx
// /routes/shop.products.tsx

type ProductSearchSortOptions = 'newest' | 'oldest' | 'price'

type ProductSearch = {
  page: number
  filter: string
  sort: ProductSearchSortOptions
}

export const Route = createFileRoute('/shop/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 as string) || '',
      sort: (search.sort as ProductSearchSortOptions) || 'newest',
    }
  },
})
// /routes/shop.products.tsx

type ProductSearchSortOptions = 'newest' | 'oldest' | 'price'

type ProductSearch = {
  page: number
  filter: string
  sort: ProductSearchSortOptions
}

export const Route = createFileRoute('/shop/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 as string) || '',
      sort: (search.sort as ProductSearchSortOptions) || '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!

Validating Search Params

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:

tsx
// /routes/shop.products.tsx

type ProductSearchSortOptions = 'newest' | 'oldest' | 'price'

type ProductSearch = {
  page: number
  filter: string
  sort: ProductSearchSortOptions
}

export const Route = createFileRoute('/shop/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 as string) || '',
      sort: (search.sort as ProductSearchSortOptions) || 'newest',
    }
  },
})
// /routes/shop.products.tsx

type ProductSearchSortOptions = 'newest' | 'oldest' | 'price'

type ProductSearch = {
  page: number
  filter: string
  sort: ProductSearchSortOptions
}

export const Route = createFileRoute('/shop/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 as string) || '',
      sort: (search.sort as ProductSearchSortOptions) || '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:

tsx
// /routes/shop.products.tsx

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>

export const Route = createFileRoute('/shop/products')({
  validateSearch: (search) => productSearchSchema.parse(search),
})
// /routes/shop.products.tsx

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>

export const Route = createFileRoute('/shop/products')({
  validateSearch: (search) => productSearchSchema.parse(search),
})

Because validateSearch also accepts an object with the parse property, this can be shortened to:

tsx
validateSearch: productSearchSchema
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 onError option will be triggered (and error.routerCode will be set to VALIDATE_SEARCH and the errorComponent will be rendered instead of the route's component where you can handle the search param error however you'd like.

Reading Search Params

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.

Using Search Params in Loaders

Please read the Search Params in Loaders section for more information about how to read search params in loaders with the loaderDeps option.

Search Params are inherited from Parent Routes

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:

  • shop.products.tsx
tsx
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>

export const Route = createFileRoute('/shop/products')({
  validateSearch: productSearchSchema,
})
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>

export const Route = createFileRoute('/shop/products')({
  validateSearch: productSearchSchema,
})
  • shop.products.$productId.tsx
tsx
export const Route = createFileRoute('/shop/products/$productId')({
  beforeLoad: ({ search }) => {
    search
    // ^? ProductSearch ✅
  },
})
export const Route = createFileRoute('/shop/products/$productId')({
  beforeLoad: ({ search }) => {
    search
    // ^? ProductSearch ✅
  },
})

Search Params in Components

You can access your route's validated search params in your route's component via the useSearch hook.

tsx
// /routes/shop.products.tsx

export const Route = createFileRoute('/shop/products')({
  validateSearch: productSearchSchema,
})

const ProductList = () => {
  const { page, filter, sort } = Route.useSearch()

  return <div>...</div>
}
// /routes/shop.products.tsx

export const Route = createFileRoute('/shop/products')({
  validateSearch: productSearchSchema,
})

const ProductList = () => {
  const { page, filter, sort } = Route.useSearch()

  return <div>...</div>
}

🧠 Quick tip: If your component is code-split, you can use the getRouteApi function to avoid having to import the Route configuration to get access to the typed useSearch() hook.

Search Params outside of Route Components

You can access your route's validated search params anywhere in your app using the useSearch hook. By passing the from id/path of your origin route, you'll get even better type safety:

tsx
const allProductsRoute = createRoute({
  getParentRoute: () => shopRoute,
  path: 'products',
  validateSearch: productSearchSchema,
})

// Somewhere else...

const routeApi = getRouteApi('/shop/products')

const ProductList = () => {
  const routeSearch = routeApi.useSearch()

  // OR

  const { page, filter, sort } = useSearch({
    from: allProductsRoute.fullPath,
  })

  return <div>...</div>
}
const allProductsRoute = createRoute({
  getParentRoute: () => shopRoute,
  path: 'products',
  validateSearch: productSearchSchema,
})

// Somewhere else...

const routeApi = getRouteApi('/shop/products')

const ProductList = () => {
  const routeSearch = routeApi.useSearch()

  // OR

  const { page, filter, sort } = useSearch({
    from: allProductsRoute.fullPath,
  })

  return <div>...</div>
}

Or, you can loosen up the type-safety and get an optional search object by passing strict: false:

tsx
function ProductList() {
  const search = useSearch({
    strict: false,
  })
  // {
  //   page: number | undefined
  //   filter: string | undefined
  //   sort: 'newest' | 'oldest' | 'price' | undefined
  // }

  return <div>...</div>
}
function ProductList() {
  const search = useSearch({
    strict: false,
  })
  // {
  //   page: number | undefined
  //   filter: string | undefined
  //   sort: 'newest' | 'oldest' | 'price' | undefined
  // }

  return <div>...</div>
}

Writing Search Params

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

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:

tsx
// /routes/shop.products.tsx
export const Route = createFileRoute('/shop/products')({
  validateSearch: productSearchSchema,
})

const ProductList = () => {
  return (
    <div>
      <Link
        from={allProductsRoute.fullPath}
        search={(prev) => ({ page: prev.page + 1 })}
      >
        Next Page
      </Link>
    </div>
  )
}
// /routes/shop.products.tsx
export const Route = createFileRoute('/shop/products')({
  validateSearch: productSearchSchema,
})

const ProductList = () => {
  return (
    <div>
      <Link
        from={allProductsRoute.fullPath}
        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 />:

tsx
// /routes/shop.products.tsx
export const Route = createFileRoute('/shop/products/$productId')({
  validateSearch: productSearchSchema,
})

const ProductList = () => {
  const navigate = useNavigate({ from: Route.fullPath })

  return (
    <div>
      <button
        onClick={() => {
          navigate({
            search: (prev) => ({ page: prev.page + 1 }),
          })
        }}
      >
        Next Page
      </button>
    </div>
  )
}
// /routes/shop.products.tsx
export const Route = createFileRoute('/shop/products/$productId')({
  validateSearch: productSearchSchema,
})

const ProductList = () => {
  const navigate = useNavigate({ from: Route.fullPath })

  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 way as the useNavigate/navigate hook/function above.

The <Navigate search /> component works exactly the same way as the useNavigate/navigate hook/function above, but accepts its options as props instead of a function argument.

Subscribe to Bytes

Your weekly dose of JavaScript news. Delivered every Monday to over 100,000 devs, for free.

Bytes

No spam. Unsubscribe at any time.