Skip to content

Instantly share code, notes, and snippets.

@oztune
Created March 9, 2026 15:25
Show Gist options
  • Select an option

  • Save oztune/3786a17882e47ddd90dec6dcb2a6aad7 to your computer and use it in GitHub Desktop.

Select an option

Save oztune/3786a17882e47ddd90dec6dcb2a6aad7 to your computer and use it in GitHub Desktop.

nuqsAdapter — Per-param search serialization for TanStack Router

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

The problem

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.

Usage

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 validateSearch and search middlewares via the second argument

nuqsAdapter.ts

// =============================================================================
// 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>,
			],
		},
	}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment