Storing React state in the URL with Next.js
Rather than keeping React UI state internal to the application memory, where it is lost on unmount and page reloads, we can sync it with the URL query string. This opens up a lot of possibilities:
- Sharing it with others
- Bookmarking it to come back to it later
- Restoring it after a page reload or browser crash
- Navigating it with the back and forward buttons
Take this example:
A couple of years ago, I wrote a library to do this in Next.js. It’s used by a fair amount of people, including Vercel on their dashboard.
GitHub updated at: 2025-09-09T22:29:36.711Z
47ng/next-usequerystate
- 8.6K
- 5.8M
- 2.6.0
- MIT
Type-safe search params state manager for React frameworks - Like useState, but stored in the URL query string.
Version rolloutLast week
For it to support the app router, a lot of internal changes were required, due to some limitations in the Next.js router and the Web History API.
But it payed off, as those changes also gave us:
- Optimal performance (now identical to
React.useState
) - Batched updates
- SSR in Server Components with correct query values (no more hydration tricks or errors)
- Better DX for creating and configuring parsers
- Finer control over options
For more details, refer to the documentation, or give the playground a try.
This post will focus on a couple of internal tricks on how to store React state in the URL with Next.js.
Shallow routing
The Next.js app router doesn’t support shallow routing, as of version 13.4.
This has caused a lot of frustration for developpers who want to update query string parameters without triggering network calls to the server.
It also slowed down initial efforts to port next-usequerystate
to the app router,
especially when binding a query state to high-frequency sources, like:
<input type="text" />
<input type="range" />
To solve these issues, a new direction has been taken: next-usequerystate
uses
a shallow mode by default, and does so by only using the Next.js router
on non-shallow updates.
This is done by tapping directly into the Web History API for shallow updates.
Batching & Throttling
One issue when connecting history.replaceState()
to a slider or a text input
is that those APIs are rate-limited by the browser.
To avoid hitting the limits, we can batch updates and throttle them under the rate limit.
Empirically, a 50ms throttle seems enough for Firefox, Chrome and Edge.
Between updates of the URL with history.{set,replace}State
, we’re just queueing
updates. Namely:
- The key to update
- The new value, or
null
to remove it from the URL - The options it requires
More recent values take precedence in the query resolution
Keeping track of options in the queue allows overriding the defaults:
- If at least one item in the queue requires a non-shallow update, call the Next.js router. Otherwise do a client-only history update.
- If at least one item in the queue requires scrolling to the top, do that. Otherwise don’t.
- History is
replaceState
by default, but if one item in the queue requests apushState
, then the whole update creates a new history entry.
Syncing state
Now that the URL updates asynchronously, we need a way to return a state value
that corresponds to what will be stored in the URL. It also needs to play
well with standard Next.js navigation with <Link>
and imperative routing calls.
Internally, the hooks use an individual React state, which get synced by query key using an event emitter.
This emitter is also used to transmit sync triggers from the history API, when external navigation occurs (not query updates). This is done by patching the History methods.
To distinguish between internal and external navigation events, we use the
unused title
second parameter of replaceState
.
We use this to our advantage, and set it to a special marker value when we’re
updating the URL internally for query changes. The patched replaceState
method
can then detect when it’s called from the outside (when the title
parameter
is not equal to our internal marker), and trigger a sync.
Optimising for performance
Make it work, make it right, make it fast. In that order.
Kent Beck
One advantage of using individual React states per hook is the performance: updates are synchronously propagated to all hooks, keeping the UI snappy.
There are a couple of tricks to make this work with state updater functions though.
If two hooks are updated in the same event loop with SUFs, we want the second one to be based on the result of the first one, as we would expect the URL to eventually behave:
const [count, setCount] = useQueryState(
'count',
parseAsInteger.withDefault(0)
)
function onClick() {
setCount(x => x + 1)
setCount(x => x * 2)
}
// First click: counter = 0
// batch update: counter = 1 (0 + 1)
// batch update: counter = 2 (1 * 2)
// Second click: counter = 2
// batch update: counter = 3 (2 + 1)
// batch update: counter = 6 (3 * 2)
In order to avoid recreating the state updater function for each state change, which breaks referential equality and de-optimises a lot of consuming code, we use a ref to store the last known state to be applied to the URL.
This is known as the latest ref pattern.
Update queue performance
While writing this post, I realised using an array for the update queue was kind of dumb: since each item overrides previous ones with the same key, we can use a Map instead, and only iterate over available keys on update.
Next.js specifics
So far, we’ve described a system that would work for any React framework. What makes it specific to Next.js is that it handles server-side rendering with correct query values. Those values can also be parsed server-side.
The shallow: false
option also allows notifying the server to query updates,
to re-render server components, or run getServerSideProps
in the pages router.
Credits
A lot of thanks to everyone who helped testing this update, and especially:
- Andrei Socaciu for laying the ground work, in #328.
- Pierre Spring for early performance testing.
- Drew Goodwin for fishing out two race-conditions on initial navigation, in #343.
- Ryan Walsh Forte for uncovering a race condition in state updater functions, in #345.
- Rich for the idea of making the parsers server-side accessible to let server components hydrate and validate query values, in #348.
- Jamie Diprose for uncovering issues with module resolution, in #352.
And thanks to Erfan for featuring this post in Next.js Weekly #24!
Freelance developer & founder