The Problem
TanStack Router is great—type-safe, well-designed, and feature-complete. But the default behavior for search params? It threw me off a bit.
...Maybe it's just me, but bear with me here 🙏
// I want this data
{ userCode: '123', ids: ['1', '2'] }
// To look like this in the URL
?userCode=123&ids=1,2
// But the default gives me this
?userCode=%22123%22&ids=%5B%221%22%2C%222%22%5D
URLs full of %22 and %5B are hard to read, sure. But what really got me was that it's not the familiar application/x-www-form-urlencoded format—the one used by HTML forms and URLSearchParams. Instead, it's JSON that's been URL-encoded.
This is fine when everything stays internal. But once external systems get involved, things get painful.
So I built an unofficial custom serializer that uses the standard URLSearchParams format.
usapopopooon
/
tanstack-router-rest-search-param-serializer
A REST API compliant search param serializer for TanStack Router.
tanstack-router-rest-search-serializer
Note: This is an unofficial community package, not affiliated with TanStack.
A REST API compliant search param serializer for TanStack Router.
Instead of using JSON.stringify / JSON.parse, this serializer uses the URLSearchParams format that conforms to REST API specifications.
Why this package?
TanStack Router serializes search parameters as JSON by default. While this works well for internal application state, it causes issues when integrating with external systems:
-
External system compatibility: URLs like
?userCode=%22123%22(JSON-encoded string) are difficult for external tools and APIs to generate - Type mismatches: Backend APIs expect standard query string formats, not JSON-encoded values
-
Non-standard format: Deviates from the industry-standard
application/x-www-form-urlencodedformat
This package provides a REST API compliant serializer that uses the standard URLSearchParams format, making URLs human-readable and compatible with any HTTP client or external system.
For more details, see the blog post (Japanese).
Table of Contents
- Why…
What Made It Painful
Pain Point 1: External Systems Can't Hit Your URLs Easily
When you expose a URL to external systems, you'd expect them to call it like ?userCode=123, right?
But with TanStack Router's default, parsing that gives you { userCode: 123 } as a number. If your app expects userCode: string, they'd need to send ?userCode="123"—which means ?userCode=%22123%22.
❌ What they'll send: ?userCode=123 → { userCode: 123 } (number)
✅ What you actually need: ?userCode=%22123%22 → { userCode: "123" } (string)
Arrays have the same issue. The formats commonly used on the web don't match what TanStack Router expects.
❌ Common web formats:
?ids=1,2,3 (comma-separated)
?ids=1&ids=2&ids=3 (duplicate keys)
?ids[]=1&ids[]=2 (bracket notation)
✅ TanStack Router default:
?ids=%5B%221%22%2C%222%22%5D (["1","2"])
Note: Bracket notation is widely used in PHP, Rails, and other frameworks for arrays and objects in query strings.
Telling external developers "please JSON-encode your strings and URL-encode that" isn't realistic. Documentation gets complicated, and compatibility with existing HTTP clients goes out the window.
And JavaScript's native URLSearchParams can't handle it either:
const url = '?userCode=%22123%22&ids=%5B%221%22%2C%222%22%5D'
const params = new URLSearchParams(url)
params.get('userCode') // → '"123"'
params.get('ids') // → '["1","2"]'
You end up locked into TanStack Router's world.
Pain Point 2: Can't Reuse Types Between Backend APIs and URLs
When you're working in TypeScript, you want the same data to use the same type everywhere:
type SearchParams = {
userCode: string
ids: string[]
}
// From a backend API
const response: SearchParams = await fetch('/api/users').then(r => r.json())
// To a backend API
await fetch('/api/users', { body: JSON.stringify(params) })
// From URL query params
const params = useSearch<SearchParams>()
// If they're all the same type, no conversion needed
But with TanStack Router's default, ?userCode=123 parses to { userCode: 123 } as a number. You've defined userCode: string, but you're getting a number. Arrays like ?ids=1,2,3 become the string '1,2,3' instead of string[].
You end up needing conversion logic between URL params and backend API params. Not fun.
Why JSON Is the Default
The TanStack Router docs explain it this way:
Search params represent application state, so inevitably, we will expect them to have the same DX associated with other state managers.
In other words, search params are treated as application state.
To provide the same DX as state management:
-
Type preservation:
123is a number,"123"is a string,trueis a boolean - Complex structures: Nested objects and arrays just work
-
Simple implementation: Just
JSON.stringify/JSON.parse
This makes total sense for internal SPA state.
But when external systems enter the picture, the mismatch with the standard application/x-www-form-urlencoded format becomes a real problem.
What I Built
Published as @usapopo/tanstack-router-rest-search-serializer.
Before / After
Parse (URL → Object):
| URL | Before (Default) | After (This Library) |
|---|---|---|
?userCode=123 |
{ userCode: 123 } (number) |
{ userCode: '123' } (string) |
?code="123" |
{ code: '123' } (quotes stripped) |
{ code: '"123"' } (5 chars with quotes) |
?ids=1,2,3 |
{ ids: '1,2,3' } (string) |
{ ids: ['1', '2', '3'] } |
?active=true |
{ active: true } |
{ active: true } |
?user[name]=john |
(can't parse) | { user: { name: 'john' } } |
Stringify (Object → URL):
| Data | Before (Default) | After (This Library) |
|---|---|---|
{ code: '123' } |
?code=%22123%22 |
?code=123 |
{ ids: ['1', '2'] } |
?ids=%5B%221%22%2C%222%22%5D |
?ids=1%2C2 (1,2) |
{ user: { name: 'john' } } |
?user=%7B%22name%22... |
?user[name]=john |
Note: Commas aren't required to be encoded per RFC 3986, but encodeURIComponent() converts them to %2C.
URLs become human-readable and compatible with external systems.
Supported Formats
Common web formats are all supported:
| Format | Query String | Parse Result |
|---|---|---|
| Standard | ?foo=bar |
{ foo: 'bar' } |
| Boolean strings | ?active=true |
{ active: true } |
| Comma-separated arrays | ?ids=1,2,3 |
{ ids: ['1', '2', '3'] } |
| Bracket notation arrays | ?ids[]=1&ids[]=2 |
{ ids: ['1', '2'] } |
| Duplicate key arrays | ?ids=1&ids=2 |
{ ids: ['1', '2'] } |
| Nested objects | ?user[name]=john |
{ user: { name: 'john' } } |
| Numeric index arrays | ?items[0]=a&items[1]=b |
{ items: ['a', 'b'] } |
Usage
Installation
npm install @usapopo/tanstack-router-rest-search-serializer
Setup
Just pass parseSearch and stringifySearch to createRouter:
// router.ts
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree'
import {
parseSearchParams,
stringifySearchParams,
} from '@usapopo/tanstack-router-rest-search-serializer'
export const router = createRouter({
routeTree,
parseSearch: parseSearchParams,
stringifySearch: stringifySearchParams,
})
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
That's it. Search params now use the URLSearchParams-compliant format.
Custom Serializer
You can create a custom serializer with specific features:
import {
createSerializer,
SIMPLE_FEATURES,
} from '@usapopo/tanstack-router-rest-search-serializer'
// Simple version (standard format + boolean conversion only)
const simple = createSerializer(SIMPLE_FEATURES)
// Pick individual features
const custom = createSerializer({
commaSeparatedArrays: true, // Comma-separated arrays
booleanStrings: true, // "true"/"false" → boolean
nestedObjects: false, // Rails-style nesting
phpArrays: false, // PHP-style arrays ids[]=1
duplicateKeyArrays: true, // Duplicate key arrays ids=1&ids=2
numericIndexArrays: false, // Numeric index items[0]=a
})
Route Definition Example
Here's how to use it with TanStack Router's route definitions and Zod validation:
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'
import { commaSeparatedArray } from '@usapopo/tanstack-router-rest-search-serializer/zod-helpers'
const searchSchema = z.object({
q: z.string().optional(),
page: z.coerce.number().default(1),
tags: commaSeparatedArray(z.string()).optional(),
active: z.boolean().optional(),
})
export const Route = createFileRoute('/search')({
validateSearch: searchSchema,
})
// URL: /search?q=hello&page=2&tags=react,typescript&active=true
// Result: { q: 'hello', page: 2, tags: ['react', 'typescript'], active: true }
Zod Helpers
When using Zod with TanStack Router's validateSearch, these helpers come in handy.
commaSeparatedArray
The comma-separated format can't distinguish between an empty array [] and an empty string ''. Parsing ?ids= gives you '', but Zod's z.array() expects an array.
commaSeparatedArray handles this conversion:
import { z } from 'zod'
import { commaSeparatedArray } from '@usapopo/tanstack-router-rest-search-serializer/zod-helpers'
const searchSchema = z.object({
ids: commaSeparatedArray(z.string()).optional(),
})
// ?ids=1,2,3 → { ids: ['1', '2', '3'] }
// ?ids= → { ids: [] }
joinCommaArray
Joins arrays back into comma-separated strings. Use this when you don't want comma-containing values to become arrays:
import { z } from 'zod'
import { joinCommaArray } from '@usapopo/tanstack-router-rest-search-serializer/zod-helpers'
const searchSchema = z.object({
// Keep as a string containing commas
freeText: joinCommaArray(z.string()).optional(),
})
// ?freeText=a,b,c → { freeText: 'a,b,c' } (string, not array)
How It Works
Here's a quick overview of the implementation.
Processing Flow
Parse (URL → Object):
"?user[name]=john&ids=1,2&active=true"
↓ 1. Extract key-value pairs with URLSearchParams
[['user[name]', 'john'], ['ids', '1,2'], ['active', 'true']]
↓ 2. Parse keys into arrays
[{ keys: ['user', 'name'], value: 'john' }, ...]
↓ 3. Set values according to key paths
{ user: { name: 'john' }, ids: '1,2', active: 'true' }
↓ 4. Transform values (boolean, comma-separated arrays)
{ user: { name: 'john' }, ids: ['1', '2'], active: true }
Stringify (Object → URL):
{ user: { name: 'john' }, ids: ['1', '2'], active: true }
↓ 1. Flatten nested objects
[['user[name]', 'john'], ['ids', '1,2'], ['active', 'true']]
↓ 2. Convert to string with URLSearchParams
"?user[name]=john&ids=1%2C2&active=true"
Key Parsing
To parse bracket notation like user[name]=john, keys need to be split up:
"user[address][city]" → ['user', 'address', 'city']
"items[0][name]" → ['items', '0', 'name']
"ids[]" → ['ids', '']
Using regex to extract [...] parts keeps it simple:
const parseNestedKey = (key: string): string[] => {
const bracketIndex = key.indexOf('[')
if (bracketIndex === -1) {
return [key]
}
const rootKey = key.slice(0, bracketIndex)
const bracketPart = key.slice(bracketIndex)
// Extract all [...] with regex
const matches = bracketPart.match(/\[([^\]]*)\]/g)
const bracketKeys = matches
? matches.map((match) => match.slice(1, -1))
: []
return rootKey ? [rootKey, ...bracketKeys] : bracketKeys
}
Value Transformation
Convert "true" / "false" to booleans, and comma-containing values to arrays:
const parseBooleanString = (value: string): boolean | string => {
if (value === 'true') return true
if (value === 'false') return false
return value
}
const parseCommaSeparated = (value: string): string[] | string => {
if (value.includes(',')) {
return value.split(',')
}
return value
}
Object Flattening
For stringify, nested objects get converted to flat key-value pairs:
flattenObject({ user: { name: 'john' } })
// → [['user[name]', 'john']]
flattenObject({ items: [{ name: 'apple' }] })
// → [['items[0][name]', 'apple']]
flattenObject({ ids: ['1', '2'] })
// → [['ids', '1,2']] ← Primitive arrays become comma-separated
Caveats
Numeric Types
With URLSearchParams-compliant format, all values are strings by default. ?amount=123 gives you { amount: '123' } as a string.
Type conversion happens at the application layer. Use z.coerce.number() with Zod in validateSearch.
Commas in Values
Values containing commas will be split into arrays. Use the joinCommaArray helper if you need to preserve them as strings.
Deep Nesting
Supported, but deeply nested structures result in long URLs.
Wrapping Up
TanStack Router's default JSON serializer works fine internally, but becomes a pain point when external systems get involved.
With this library:
- URLs are human-readable
- External systems can call your URLs normally
- You can reuse the same types between backend APIs and URLs
There might still be edge cases I haven't caught, so issues are welcome 🙏
Top comments (0)