T
TanStack4w ago
wise-white

Custom Search Param Serialization

Hi, I’m running into an issue with search param serialization in TanStack Router. When I pass arrays into search, the URL looks like this:
https://example.com/route?country=%5B%22DNK%22%2C%22FIN%22%5D
https://example.com/route?country=%5B%22DNK%22%2C%22FIN%22%5D
That’s the array JSON-encoded into a single query param value. What I’d like instead is the more standard repeated-keys format:
https://example.com/route?country=DNK&country=FIN&page=2
https://example.com/route?country=DNK&country=FIN&page=2
I tried following the docs with query-string:
const router = createRouter({
stringifySearch: stringifySearchWith((value) =>
qs.stringify(value),
),
parseSearch: parseSearchWith((value) =>
qs.parse(value),
),
})
const router = createRouter({
stringifySearch: stringifySearchWith((value) =>
qs.stringify(value),
),
parseSearch: parseSearchWith((value) =>
qs.parse(value),
),
})
I'm using zod to validate search params in my routes:
const schema = z.object({
country: z.array(z.string()).optional(),
})
const schema = z.object({
country: z.array(z.string()).optional(),
})
But the problem is that the value passed into stringifySearchWith isn’t an array. Instead I’m seeing objects with only number–value pairs like {0: "DNK", 1: "FIN"}. That means they end up in the URL as ?country=0%3DFIN. I don’t even have access to the original key. This break my validation logic where array is expected:
Something went wrong!

[
{
"expected": "array",
"code": "invalid_type",
"path": [
"country"
],
"message": "Invalid input: expected array, received object"
}
]
Something went wrong!

[
{
"expected": "array",
"code": "invalid_type",
"path": [
"country"
],
"message": "Invalid input: expected array, received object"
}
]
What’s the recommended way in TanStack Router to produce repeated query keys (?country=DNK&country=FIN) and parse them back into arrays for search params? Any help here would be appreciated 🙏
25 Replies
adverse-sapphire
adverse-sapphire4w ago
You might have better success by doing
{
stringifySearch: qs.stringify // Use `(search) => qs.stringify(search, { arrayFormat: "bracket" })` for consistent array parsing,
parseSearch: qs.parse
}
{
stringifySearch: qs.stringify // Use `(search) => qs.stringify(search, { arrayFormat: "bracket" })` for consistent array parsing,
parseSearch: qs.parse
}
stringifySearchWith iterates over each key:value pair and maps the value using the stringify function you pass in, but you dont want a 1:1 mapping, you want 1 key (your array) to map to multiple keys (all with the same name of country) i also recommend using the bracket array format instead, as this will lead to some uncertainty in some cases, what if you have an array of 1 item? you'd set only one ?country=ONE then when you need to parse it back, do you parse it as a string or as an array?
wise-white
wise-whiteOP4w ago
Thank you for the quick response @ferretwithabéret I'm migrating from React Router to TanStack Router and I’d like my clients’ existing bookmarks to keep working after the migration. So I’m looking for exact-match URLs that contain separate key–value pairs, for example: route?country=DNK&country=FIN It seems to be working almost like that, but for some reason I need to manually return the ? symbol as well. Otherwise I end up with:: routecountry=DNK&country=FIN&page=2. Is that expected behavior?? This is what worked for me:
{
stringifySearch: (search) => `?${qs.stringify(search)}`
parseSearch: qs.parse
}
{
stringifySearch: (search) => `?${qs.stringify(search)}`
parseSearch: qs.parse
}
And then in my schema I have to transform a string into a string array:
country: z.union([z.string(), z.array(z.string())]).transform((val) =>
Array.isArray(val) ? val : val ? [val] : undefined
).optional(),
country: z.union([z.string(), z.array(z.string())]).transform((val) =>
Array.isArray(val) ? val : val ? [val] : undefined
).optional(),
I suspect this might be doing more than it should just to achieve the desired result, and I may have something off here. Wdyt? 🤔
adverse-sapphire
adverse-sapphire4w ago
that should be fine, but now you will always have to work with arrays for those params, not sure how you had it setup with react router nvm, just realized those are just the params that you expect to be arrays, this should be fine
wise-white
wise-whiteOP4w ago
Yes, it was always considered as an array, also with react router... Thank for the help here 🙏
adverse-sapphire
adverse-sapphire4w ago
about the ?, tanstack router was adding it here: https://github.com/TanStack/router/blob/22c9888857aca20cdffbf8f97927add139aaa5ea/packages/router-core/src/searchParams.ts#L61 ig qs returns it without and router expects it with, so should be fine you might want to check if the result of qs.stringify is truthy, like they do, to avoid adding just the ? to the url
wise-white
wise-whiteOP4w ago
Oh right. makes sense.. I will add that. 🙂
adverse-sapphire
adverse-sapphire4w ago
You might also be able to hint it to qs that it is supposed to be parsed as an array, but you need to get your route schema in the parser somehow, which I am not sure if it is possible https://www.npmjs.com/package/query-string#types found this option
genetic-orange
genetic-orange4w ago
about migration, you might also just use a url rewrite
genetic-orange
genetic-orange4w ago
Manuel Schiller (@schanuelmiller)
Soon in TanStack Router/Start: URL rewrites This opens up a ton of possibilities such as - i18n with translated URL paths - multi-tenant apps with subdomains
From Manuel Schiller (@schanuelmiller)
X
genetic-orange
genetic-orange4w ago
to convert "old" into "new"
wise-white
wise-whiteOP4w ago
I will look into that one 🙂 Thanks. Cool stuff. Do you have an estimate of when this will be available?
genetic-orange
genetic-orange4w ago
pretty soon
adverse-sapphire
adverse-sapphire2d ago
Hey, is there any news about when the feature will be released, or did I already miss the release?
genetic-orange
genetic-orange18h ago
yes it's released just not documented yet
adverse-sapphire
adverse-sapphire15h ago
nice, have you maybe an example?
genetic-orange
genetic-orange15h ago
GitHub
router/e2e/react-start/serialization-adapters at main · TanStack/r...
🤖 Fully typesafe Router for React (and friends) w/ built-in caching, 1st class search-param APIs, client-side cache integration and isomorphic rendering. - TanStack/router
adverse-sapphire
adverse-sapphire14h ago
hm? i see nothing about the rewrites
genetic-orange
genetic-orange14h ago
oh sorry wrong example here are some tests
genetic-orange
genetic-orange14h ago
GitHub
router/packages/react-router/tests/link.test.tsx at cc52bd792e50083...
🤖 Fully typesafe Router for React (and friends) w/ built-in caching, 1st class search-param APIs, client-side cache integration and isomorphic rendering. - TanStack/router
genetic-orange
genetic-orange14h ago
GitHub
router/packages/react-router/tests/router.test.tsx at cc52bd792e500...
🤖 Fully typesafe Router for React (and friends) w/ built-in caching, 1st class search-param APIs, client-side cache integration and isomorphic rendering. - TanStack/router
genetic-orange
genetic-orange14h ago
what do you want to do with rewrites?
adverse-sapphire
adverse-sapphire14h ago
multi tenant app with subdomains
genetic-orange
genetic-orange14h ago
ok so the link.test.tsx should help if you need some more details, let me know
adverse-sapphire
adverse-sapphire11h ago
can i make the input/output async to fetch some data from the database? here are some route examples: carehaven.de <- the root domain must be handle normal for api calls and something else test1.carehaven.de/dashboard -> /org/<id from test1 org>/dashboard or maybe test1.carehaven.de/dashboard -> /org/dashboard and the i make a middleware to fetch the orgId from subdomain and set it in to the router context or get it in beforeLoad on the route component
genetic-orange
genetic-orange11h ago
no it cannot be async as we need it to be sync for link building you need to perform those fetches outside of the rewrite can you explain in more detail what you would need to fetch, and based on what?

Did you find this page helpful?