T
TanStack3mo ago
foreign-sapphire

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 🙏
26 Replies
inland-turquoise
inland-turquoise3mo 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?
foreign-sapphire
foreign-sapphireOP3mo 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? 🤔
inland-turquoise
inland-turquoise3mo 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
foreign-sapphire
foreign-sapphireOP3mo ago
Yes, it was always considered as an array, also with react router... Thank for the help here 🙏
inland-turquoise
inland-turquoise3mo 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
foreign-sapphire
foreign-sapphireOP3mo ago
Oh right. makes sense.. I will add that. 🙂
inland-turquoise
inland-turquoise3mo 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
exotic-emerald
exotic-emerald3mo ago
about migration, you might also just use a url rewrite
exotic-emerald
exotic-emerald3mo 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
exotic-emerald
exotic-emerald3mo ago
to convert "old" into "new"
foreign-sapphire
foreign-sapphireOP3mo ago
I will look into that one 🙂 Thanks. Cool stuff. Do you have an estimate of when this will be available?
exotic-emerald
exotic-emerald3mo ago
pretty soon
deep-jade
deep-jade2mo ago
Hey, is there any news about when the feature will be released, or did I already miss the release?
exotic-emerald
exotic-emerald2mo ago
yes it's released just not documented yet
deep-jade
deep-jade2mo ago
nice, have you maybe an example?
exotic-emerald
exotic-emerald2mo 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
deep-jade
deep-jade2mo ago
hm? i see nothing about the rewrites
exotic-emerald
exotic-emerald2mo ago
oh sorry wrong example here are some tests
exotic-emerald
exotic-emerald2mo 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
exotic-emerald
exotic-emerald2mo 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
exotic-emerald
exotic-emerald2mo ago
what do you want to do with rewrites?
deep-jade
deep-jade2mo ago
multi tenant app with subdomains
exotic-emerald
exotic-emerald2mo ago
ok so the link.test.tsx should help if you need some more details, let me know
deep-jade
deep-jade2mo 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
exotic-emerald
exotic-emerald2mo 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?
deep-jade
deep-jade2mo ago
i have solved it so:
export const getRouter = () => {
const rqContext = TanstackQuery.getContext()

const router = createRouter({
routeTree,
rewrite: {
input: ({ url }) => {
const host = url.hostname;
if (host === 'localhost' || host === process.env.ROOT_DOMAIN) {
if (url.pathname.startsWith('/org')) {
url.pathname = "/";
}
return url;
}
const subdomain = host.split('.')[0]
if (subdomain && subdomain !== 'www' && subdomain !== 'localhost') {
if (subdomain === 'master') {
url.pathname = `/master${url.pathname}`;
return url;
}
url.pathname = `/org${url.pathname}`;
}
return url;
}
},
context: { ...rqContext },
defaultPreload: 'intent',
Wrap: (props: { children: React.ReactNode }) => {
return (
<TanstackQuery.Provider {...rqContext}>
{props.children}
</TanstackQuery.Provider>
)
},
})

setupRouterSsrQueryIntegration({ router, queryClient: rqContext.queryClient })

return router
}
export const getRouter = () => {
const rqContext = TanstackQuery.getContext()

const router = createRouter({
routeTree,
rewrite: {
input: ({ url }) => {
const host = url.hostname;
if (host === 'localhost' || host === process.env.ROOT_DOMAIN) {
if (url.pathname.startsWith('/org')) {
url.pathname = "/";
}
return url;
}
const subdomain = host.split('.')[0]
if (subdomain && subdomain !== 'www' && subdomain !== 'localhost') {
if (subdomain === 'master') {
url.pathname = `/master${url.pathname}`;
return url;
}
url.pathname = `/org${url.pathname}`;
}
return url;
}
},
context: { ...rqContext },
defaultPreload: 'intent',
Wrap: (props: { children: React.ReactNode }) => {
return (
<TanstackQuery.Provider {...rqContext}>
{props.children}
</TanstackQuery.Provider>
)
},
})

setupRouterSsrQueryIntegration({ router, queryClient: rqContext.queryClient })

return router
}
Layout:
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
import db from '@/lib/db.ts'
import { clientEnv } from '@/config/env.ts'

export const Route = createFileRoute('/org')({
component: Layout,
beforeLoad: async ({ location }) => {
const subdomain = (location.url
.replace('http://', '')
.replace('https://', '')
.split('/')[0])
.split('.')[0]

if (subdomain && subdomain !== 'www' && subdomain !== 'app') {
const data = await db.organization.findUnique({
where: { subdomain },
})

if (!data) {
throw redirect({ href: `http://${clientEnv.VITE_ROOT_DOMAIN}` })
}

console.log(`Accessing organization: ${subdomain}`)
return { organization: data }
}
}
})

function Layout() {
return (
<div>
<Outlet />
</div>
)
}
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
import db from '@/lib/db.ts'
import { clientEnv } from '@/config/env.ts'

export const Route = createFileRoute('/org')({
component: Layout,
beforeLoad: async ({ location }) => {
const subdomain = (location.url
.replace('http://', '')
.replace('https://', '')
.split('/')[0])
.split('.')[0]

if (subdomain && subdomain !== 'www' && subdomain !== 'app') {
const data = await db.organization.findUnique({
where: { subdomain },
})

if (!data) {
throw redirect({ href: `http://${clientEnv.VITE_ROOT_DOMAIN}` })
}

console.log(`Accessing organization: ${subdomain}`)
return { organization: data }
}
}
})

function Layout() {
return (
<div>
<Outlet />
</div>
)
}
Now I can load everything I need from the layout, from data to the theme, etc. thank you for your help 🙂

Did you find this page helpful?