DEV Community

Cover image for I Built a URLSearchParams-Compliant Search Param Serializer for TanStack Router
usapopopooon
usapopopooon

Posted on

I Built a URLSearchParams-Compliant Search Param Serializer for TanStack Router

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
Enter fullscreen mode Exit fullscreen mode

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.

GitHub logo usapopopooon / tanstack-router-rest-search-param-serializer

A REST API compliant search param serializer for TanStack Router.

tanstack-router-rest-search-serializer

CI npm version License: MIT

TanStack Router REST Search Param 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-urlencoded format

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

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)
Enter fullscreen mode Exit fullscreen mode

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"])
Enter fullscreen mode Exit fullscreen mode

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"]'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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: 123 is a number, "123" is a string, true is 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
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
})
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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: [] }
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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', '']
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)