Search Params

The router treats the query string as structured, validated, typed state — not a bag of strings. Search params are parsed (with JSON support for nested values), validated per route, and flow into matches and loaders fully typed.

validateSearch

Declare a route's search schema with validateSearch. The raw parsed search comes in, your typed schema comes out:

type PostsSearch = {
    page: number
    filter: string
}

const postsRoute = createRoute({
    getParentRoute: () => rootRoute,
    path: "/posts",
    validateSearch: (search: Record<string, unknown>): PostsSearch => ({
        page: Number(search.page ?? 1),
        filter: (search.filter as string) ?? "",
    }),
})

Any Standard Schema validator (Zod, Valibot, ArkType...) can be passed directly instead of a function:

validateSearch: z.object({
    page: z.number().catch(1),
    filter: z.string().catch(""),
})

If validation throws, the match errors with a SearchParamError — surface it from match.status === "error" / match.error.

The validated result lives on the match (parent schemas are merged in):

const match = matches.get(l).find((m) => m.routeId === postsRoute.id)
match?.search.page   // number — typed by validateSearch

And in loaders — but go through loaderDeps, not search directly (see Data Loading):

loaderDeps: ({ search }) => ({ page: search.page }),
loader: ({ deps }) => fetchPosts(deps.page),

search accepts an object, true to keep the current params, or an updater function — the functional form is the idiomatic one for "change one param, keep the rest":

router.navigate({ to: "/posts", search: { page: 2, filter: "" } })

// update relative to the current search
router.navigate({
    to: ".",
    search: (prev) => ({ ...prev, page: prev.page + 1 }),
})

// different route, keep whatever search is currently in the URL
router.navigate({ to: "/posts", search: true })

The same search option works in buildLocation for hrefs and in redirect().

Search Middleware

Middlewares run on every link build and navigation for a route, transforming the outgoing search before it hits the URL. They are declared on the route's search.middlewares:

import { retainSearchParams, stripSearchParams } from "@domphy/router"

retainSearchParams

Keeps search params alive across navigations that would otherwise drop them — for app-wide params like feature flags or an active workspace:

const rootRoute = createRootRoute({
    validateSearch: (search: Record<string, unknown>) => ({
        workspace: (search.workspace as string) ?? undefined,
    }),
    search: {
        middlewares: [retainSearchParams(["workspace"])],
    },
})

retainSearchParams(true) retains everything currently in the URL.

stripSearchParams

Removes noise from URLs — params equal to their defaults disappear:

const defaults = { page: 1, filter: "" }

const postsRoute = createRoute({
    getParentRoute: () => rootRoute,
    path: "/posts",
    validateSearch: (search: Record<string, unknown>) => ({
        page: Number(search.page ?? defaults.page),
        filter: (search.filter as string) ?? defaults.filter,
    }),
    search: {
        middlewares: [stripSearchParams(defaults)],
    },
})

/posts?page=1&filter= becomes /posts. Variants: pass an array of keys to always strip them, or true to strip all (only when no search params are required).

Custom Serialization

By default search is serialized with defaultStringifySearch (JSON-aware: ?ids=[1,2] round-trips as an array). Swap the codec router-wide:

import { createRouter, parseSearchWith, stringifySearchWith } from "@domphy/router"

const router = createRouter({
    routeTree,
    history,
    parseSearch: parseSearchWith(JSON.parse),
    stringifySearch: stringifySearchWith(JSON.stringify),
})