Bridges nuqs parsers to TanStack Router's validateSearch + SearchMiddleware, giving you per-param control over how search params serialize to/from the URL.
Related issue: TanStack/router#4973
TanStack Router's validateSearch and stringifySearch operate on the entire search object. There's no built-in way to control serialization per-param. For types like date ranges that serialize to ?dates=30-days or ?dates=2024-01-15_2024-02-20, you need per-param codecs — not raw JSON in the URL.
import { nuqsAdapter } from './nuqsAdapter'
import { parseAsInteger, parseAsString, parseAsArrayOf } from 'nuqs'
export const Route = createFileRoute('/reports/example')({
...nuqsAdapter({
page: parseAsInteger.withDefault(1),
sort: parseAsString.withDefault('name'),
tags: parseAsArrayOf(parseAsString).withDefault([]),
}),
loader: ({ context }) => {
// useSearch() returns { page: number, sort: string, tags: string[] }
// fully typed, no string parsing needed
},
component: RouteComponent,
})- Params with
.withDefault()become optional in<Link search={...}> - Each param controls its own URL representation
- Full type safety:
useSearch()returns the parsed types, not strings - Composes with existing
validateSearchand search middlewares via the second argument
// =============================================================================
// nuqsAdapter - Bridges nuqs parsers to TanStack Router's validateSearch + middlewares
// Fills the gap identified in https://github.com/TanStack/router/issues/4973
// =============================================================================
import type {
SearchMiddleware,
SearchMiddlewareContext,
ValidatorAdapter,
} from '@tanstack/router-core'
import type { inferParserType, SingleParserBuilder } from 'nuqs'
// A parser that has been given a default value via .withDefault()
type ParserWithDefault<T> = SingleParserBuilder<T> & { readonly defaultValue: T }
// Map of parsers — each may or may not have .withDefault()
type Parsers = Record<string, SingleParserBuilder<any>>
// Keys whose parser has .withDefault()
type KeysWithDefaults<T extends Parsers> = {
[K in keyof T]: T[K] extends { readonly defaultValue: any } ? K : never
}[keyof T]
// Input type for navigation (Link's `search` prop):
// - Keys with defaults are optional (validator fills in the default)
// - Keys without defaults are required
type NuqsSearchInput<T extends Parsers> = {
[K in KeysWithDefaults<T>]?: inferParserType<T[K]>
} & {
[K in Exclude<keyof T, KeysWithDefaults<T>>]: inferParserType<T[K]>
}
// Mirrors TanStack Router's route search options
type RouteSearchOptions<TSearch> = {
validateSearch?: ValidatorAdapter<Record<string, unknown>, TSearch>
search?: {
middlewares?: SearchMiddleware<TSearch>[]
}
}
/**
* Bridges nuqs parsers to TanStack Router's validateSearch + search.middlewares.
*
* @example
* ;```tsx
* // Basic usage
* export const Route = createFileRoute('/reports/example')({
* component: RouteComponent,
* ...nuqsAdapter({
* dateRange: parseAsHybridDateRange.withDefault(DynamicDateRange.createLastN(30, 'day')),
* page: parseAsInteger.withDefault(1),
* }),
* })
*
* // Migrating existing route - pass old options as second param
* export const Route = createFileRoute('/reports/example')({
* component: RouteComponent,
* ...nuqsAdapter({
* dateRange: parseAsHybridDateRange.withDefault(...),
* }, {
* // Existing validateSearch - for non-nuqs keys
* validateSearch: { types: {...}, parse: (input) => ({...}) },
* // Existing middlewares - run after nuqs serialization
* search: {
* middlewares: [existingMiddleware],
* },
* }),
* })
* ```
*/
export function nuqsAdapter<T extends Parsers, TExtra = {}>(
parsers: T,
existing?: RouteSearchOptions<TExtra>
) {
type TSearchInput = NuqsSearchInput<T> & TExtra
type TSearchOutput = inferParserType<T> & TExtra
// ValidatorAdapter<TInput, TOutput>:
// - TInput (types.input) controls what Link's `search` prop accepts (fullSearchSchemaInput)
// - TOutput (types.output) controls what useSearch() returns (fullSearchSchema)
const validateSearch: ValidatorAdapter<TSearchInput, TSearchOutput> = {
types: {
input: {} as TSearchInput,
output: {} as TSearchOutput,
},
parse: (input: unknown): TSearchOutput => {
const typedInput = input as Record<string, unknown>
const result: Record<string, unknown> = {}
// Existing validateSearch first (for non-nuqs keys)
if (existing?.validateSearch) {
Object.assign(result, existing.validateSearch.parse(typedInput))
}
// Nuqs parsing overwrites its keys (nuqs is authoritative)
for (const [key, parser] of Object.entries(parsers)) {
const rawValue = typedInput[key]
if (typeof rawValue === 'string') {
// Parse string from URL
const parsed = parser.parse(rawValue)
result[key] =
parsed !== null
? parsed
: 'defaultValue' in parser
? (parser as ParserWithDefault<unknown>).defaultValue
: null
} else if (rawValue != null) {
// Already parsed (e.g., from internal navigation) - use as-is
result[key] = rawValue
} else {
// Missing → use default if available, otherwise null
result[key] =
'defaultValue' in parser
? (parser as ParserWithDefault<unknown>).defaultValue
: null
}
}
return result as TSearchOutput
},
}
return {
validateSearch,
search: {
middlewares: [
// Existing middlewares run before
...(existing?.search?.middlewares ?? []),
// Nuqs serialization middleware (nuqs keys always serialized)
// Cast to SearchMiddleware<any> to avoid breaking TanStack Router's type inference.
// The router expects SearchMiddleware<ResolveFullSearchSchemaInput<TParentRoute, TSearchValidator>>
// but we only know TSearch (local search type). The middleware only touches nuqs-managed
// keys so this is safe at runtime.
((ctx: SearchMiddlewareContext<TSearchOutput>) => {
const out = ctx.next(ctx.search)
const encoded: Record<string, unknown> = { ...out }
// Serialize nuqs-managed keys (overwrites any existing serialization)
for (const [key, parser] of Object.entries(parsers)) {
const value = (out as Record<string, unknown>)[key]
if (value !== undefined) {
encoded[key] = parser.serialize(value)
}
}
return encoded as unknown as TSearchOutput
}) as SearchMiddleware<any>,
],
},
}
}