# URL state in React: nuqs, state-in-url, or your own hook

For filter and search state in React, choose by what owns the URL. nuqs suits apps with a router: typed parsers, router adapters, 5,901 bytes gzipped. state-in-url suits one nested object without a router: 2,488 bytes, but it quotes strings and encodes objects. A hand-written hook costs 451 bytes and suits a few flat strings with one writer. Sizes measured 2026-10-07 with esbuild, React external.

Published: 2026-10-07
Canonical: https://umar.codes/url-state-in-react

## Revisions

- 2026-10-07 — Created; sizes and URLs measured the same day against nuqs 2.10.1, state-in-url 8.0.0 and ag-grid-url-sync 0.3.0, with esbuild 0.28.2.
- 2026-10-07 — Published, two days after its slot; auto-drafted by the publishing run because no draft existed for the row.

---

Use nuqs when a router owns the URL and the state is a handful of typed
parameters; it adds 5,901 bytes gzipped. Use state-in-url for one nested state
object without a router, at 2,488 bytes. Write your own hook, about 451 bytes,
when the state is a few flat strings and nothing else writes the URL.

## Bundle size of nuqs, state-in-url and a hand-written hook

I gave each option the same job: a filter bar with three fields, `status`,
`page` and `q`, each read from and written to the query string. I bundled each
version with esbuild 0.28.2 as minified ESM, with React marked external because
the app already pays for it, and compressed the output with gzip at level 9 and
with brotli. Every bundle includes the same small component, so the differences
are the libraries.

| Option | Version | Minified | Gzip | Brotli | Runtime dependencies |
| --- | --- | --- | --- | --- | --- |
| nuqs, plain React adapter | 2.10.1 | 15,458 B | 5,901 B | 5,312 B | 1 (`@standard-schema/spec`) |
| state-in-url, `state-in-url/react` | 8.0.0 | 6,014 B | 2,488 B | 2,251 B | 0 |
| Hand-written hook | — | 792 B | 451 B | 383 B | 0 |
| ag-grid-url-sync, React hook | 0.3.0 | 18,127 B | 5,713 B | 5,146 B | 0 |

The last row is not a general URL-state library. It is my AG Grid filter
library, measured with the same settings, and it is here as the worked example
further down. All four numbers are for 2026-10-07; a new release can move them,
and this page's revision log records each re-measurement.

## What each option writes into the URL

Size is the cost you pay once. The URL is the cost every user sees, every time
they share a link. I serialised the same state with each library's own encoder.

For flat state, `{ status: "open", page: 2, q: "o'brien smith" }`:

| Option | Query string | Characters |
| --- | --- | --- |
| nuqs | `?status=open&page=2&q=o%27brien+smith` | 37 |
| Hand-written hook (`URLSearchParams`) | `?status=open&page=2&q=o%27brien+smith` | 37 |
| state-in-url | `?status=%27open%27&page=2&q=%27o%2527brien+smith%27` | 51 |

state-in-url wraps each string in quotes, so it can tell the string `"2"` from
the number `2` when it reads the URL back. The quotes are percent-encoded, and
the apostrophe inside the value is encoded twice: `%2527`, not `%27`. Nothing
breaks, because the library decodes its own output. But a person who reads that
link, or edits it by hand, sees the encoding.

For nested state, `{ tags: ["vip", "late"], range: { min: 25, max: 45 } }`:

| Option | Query string | Characters |
| --- | --- | --- |
| nuqs, `parseAsArrayOf` + `parseAsJson` | `?tags=vip,late&range={%22min%22:25,%22max%22:45}` | 48 |
| state-in-url | `?tags=%5B%27vip%27%2C%27late%27%5D&range=%7B%27min%27%3A25%2C%27max%27%3A45%7D` | 78 |
| ag-grid-url-sync, the range alone | `?f_age_range=25,45` | 18 |

The pattern under all three rows: a library that knows nothing about the shape
of your data has to write that shape into the URL. One that knows the shape
does not. nuqs knows the shape key by key, because you give it a parser per
key. ag-grid-url-sync knows it completely, because an AG Grid filter has a
fixed structure, so 18 characters carry a range that costs state-in-url 44 on
its own.

With a default value set, both nuqs and state-in-url write nothing for a field
that equals its default. A clean view is a clean URL in both.

## When nuqs is the right choice

nuqs gives you one hook per parameter, `useQueryState`, with a parser for each
type: `parseAsInteger`, `parseAsArrayOf`, `parseAsJson` and others. It ships
adapters for Next.js, React Router 6, 7 and 8, Remix, TanStack Router, and
plain React, so the router hears every URL write. It also throttles writes: in
the 2.10.1 source the default is 50 ms, raised to 120 ms on Safari 17 and later
and 320 ms on older Safari, because Safari limits how often a page may call the
history API.

I use it in production, in the admin of a bookings app, with the React Router
v7 adapter. The two settings that cost me debugging time are in [the URL-state
essay](/url-as-state-manager): shallow updates are the default, so a
server-rendered app needs `shallow: false` or its loaders stop matching the
URL, and the adapter must match the router or nothing updates while everything
type-checks.

Pick nuqs when a router is in the app, when the parameters are separate
controls, and when 5.9 kB gzipped is small next to what the page already loads.

## When state-in-url is the right choice

state-in-url takes one default object and returns the whole state:
`const { urlState, setUrl, reset } = useUrlState(defaults)`. The types come
from the default object. Arrays and nested objects round-trip exactly: the
nested state above decoded back to `{"tags":["vip","late"],"range":{"min":25,"max":45}}`.
It has no runtime dependencies, and it has entry points for Next.js, React
Router, Remix, plain React and Astro islands.

To hear URL changes that it did not make, state-in-url replaces
`window.history.pushState` and `replaceState` once, on first subscribe, with
wrappers that notify its listeners. That is why it works with no adapter. It is
also a global change to the page, which is worth knowing before you debug
someone else's history call.

Pick it when the state is one object that belongs together, when no router is
in charge, and when people will share the link but not edit it. I have not
shipped it in production; everything here comes from measuring it and reading
the 8.0.0 source.

## When a hand-written hook is enough

This is the hook I measured, minus the test component. It reads one parameter
with `useSyncExternalStore` and writes it back with the history API:

```js
import { useCallback, useSyncExternalStore } from "react";

const subscribe = (cb) => {
  window.addEventListener("popstate", cb);
  window.addEventListener("urlchange", cb);
  return () => {
    window.removeEventListener("popstate", cb);
    window.removeEventListener("urlchange", cb);
  };
};

export function useSearchParam(key, fallback = "") {
  const value = useSyncExternalStore(
    subscribe,
    () => new URLSearchParams(location.search).get(key),
    () => null,
  );
  const set = useCallback((next, { push = false } = {}) => {
    const params = new URLSearchParams(location.search);
    if (next === fallback || next === "") params.delete(key);
    else params.set(key, String(next));
    const qs = params.toString();
    history[push ? "pushState" : "replaceState"](null, "", qs ? `?${qs}` : location.pathname);
    window.dispatchEvent(new Event("urlchange"));
  }, [key, fallback]);
  return [value ?? fallback, set];
}
```

It costs 451 bytes gzipped, and it has three limits that the libraries exist
to remove. It returns strings, so every number and boolean is parsed by hand.
It hears the back button and its own writes, but not a router's `pushState`,
which is the gap that the nuqs adapters and the state-in-url patch both close.
And it does not throttle, so a search box that writes on every keystroke calls
the history API on every keystroke.

Pick it for two or three flat parameters on a page with no router, where you
would rather read 27 lines than a changelog.

## A fixed grammar beats a general encoder

[ag-grid-url-sync](/ag-grid-url-sync) is the case where none of the three
general options fits. An AG Grid filter model is a tree: a filter type, an
operation and one or two values for each column. A general encoder writes that
tree as JSON. My library writes it as one parameter per column, with 18
operation tokens that cover all 26 AG Grid operations, so the age range above
is `f_age_range=25,45`. The full token list is on [the grammar reference
page](/ag-grid-url-sync-grammar).

It costs 5,713 bytes gzipped, about the same as nuqs, and it only does one
thing. That is the trade: when the state has a fixed structure that someone
will read, a grammar written for that structure gives the shortest and most
readable URL. When the structure is your app's and changes every sprint, a
general library is the better cost.

## How to rerun these numbers

Each measurement is one esbuild call per entry file: `bundle: true`,
`minify: true`, `format: "esm"`, `jsx: "automatic"`, and React, React DOM and
every router package marked external. Gzip is Node's `zlib.gzipSync` at level
9, and brotli is `brotliCompressSync` with its defaults. The URLs come from
each library's own serialiser: nuqs `createSerializer`, state-in-url
`encodeState`, and `URLSearchParams` for the hook. Change the version, rerun,
and compare against the tables above.
