#1590·nuqs

bug: TanStack Router adapter adds a second query string on routes with a dynamic segment

Author: selemondevCreated Sep 12, 2026Updated Sep 15, 2026

Context

What's your version of nuqs?

"nuqs": "2.10.1"

What framework are you using?

  • ❌ Next.js (app router)
  • ❌ Next.js (pages router)
  • ❌ React SPA (no router)
  • ❌ Remix
  • ❌ React Router
  • ✅ Other (TanStack Router)

Which version of your framework are you using?

"@tanstack/react-router": "1.170.34"

Description

With the TanStack Router adapter, a useQueryState write can add a second query string to the URL. The route's validateSearch then reads the second one, so the value I just set is thrown away and the control looks broken.

It happens when the route has a dynamic segment. updateUrl navigates with to: pathname + renderQueryString(search), but to is a path. buildLocation in router-core only splits a query string out of href, never out of to, so the whole string becomes the pathname and the router appends its own search on top.

I expected the write to produce one query string.

Reproduction

Route: /database/$table with a validateSearch that has defaults.

  1. Open /database/customers?schema=public&limit=25&offset=0&view=data
  2. Click a button that calls setView("structure"), where view is useQueryState("view", parseAsStringLiteral(["data", "structure"]).withDefault("data"))
  3. The URL becomes:
/database/customers?schema=public&limit=25&offset=0&view=structure?schema=public&limit=50&offset=0&view=data

limit=50 is the route default, not the limit=25 that was on screen, so the tail comes from route resolution.

Note on reproducing it: on a route with no dynamic segment the glued path matches nothing, the appended search is empty, and the browser splits the pathname back correctly. So it only shows on routes like $table.

Still there with router-core 1.171.29.

Possible fix

Pass a search object instead of a query string:

typescript
navigate({
  to: pathname,
  search: objectFromSearchParams(next),
  replace: options.history === "replace",
  resetScroll: options.scroll,
})

One thing to watch: objectFromSearchParams needs to turn repeated keys into an array, or routers with a custom parseSearch that supports ?filter=a&filter=b lose all but the last value.

I have this working locally with unstable_createAdapterProvider. Happy to send a PR if you want it.