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

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: 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:

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 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.

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.

Revisions

  1. 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.
  2. Published, two days after its slot; auto-drafted by the publishing run because no draft existed for the row.