nuqs
nuqs makes React URL search parameters behave like typed state. Hooks read and update query keys, built-in parsers convert strings to integers, booleans, dates, arrays, JSON, enums, and literals, and server helpers reuse the same parser definitions without client code. Framework adapters connect those operations to Next.js, plain React, Remix, React Router, or TanStack Router. The URL remains the source of truth, which makes filters and pagination shareable, bookmarkable, and compatible with browser navigation.
The best fit when URL state is a product feature rather than incidental string parsing, especially across Next.js client and server boundaries. Keep local or sensitive state out of it, and add real validation after parsing untrusted URLs.
Use it if
- Search, filters, sorting, tabs, pagination, or map position should survive refreshes and be shareable by URL
- You want one typed parser definition shared by React hooks, server components, loaders, and link serialization
- You need batched multi-key updates with deliberate replace-versus-push history behavior
- Your app uses a supported Next.js, React SPA, Remix, React Router, or TanStack Router adapter
- The state is private, large, high-frequency, or security-sensitive; query strings are visible, copied, logged, length-limited, and sent in referrers
- You need schema validation rather than parsing; the README explicitly says parsers do not validate constraints such as positive integers or JSON object shapes
- You cannot wrap the React tree with a router-specific adapter; version 2 requires an adapter and each framework has its own entry point
- You expect every keystroke to trigger server rendering; updates are shallow by default and History API writes are queued and throttled, with stricter practical limits in Safari
- You use TanStack Start and expect supported server integration; the README labels TanStack Router support experimental and says it does not cover TanStack Start
Setup reality
Install with `npm install nuqs`, then add exactly one adapter around the relevant React tree. Next.js App Router and Pages Router use different imports; React Router has versioned v6, v7, and v8 entries; Remix, plain React, and TanStack Router have their own adapters. The package peers on React 18.2 or React 19 plus whichever optional router you actually use, including Next 14.2 or newer. Hooks are client APIs, so Next App Router files using them need `'use client'`; server components should import parsers, loaders, serializers, and caches from `nuqs/server` to avoid the client directive. Missing query keys return null unless a parser uses `.withDefault()`, but that default is internal and is not written into the URL. Setting null removes a key. Parsing is not validation: `parseAsInteger` converts syntax but does not enforce positive ranges, and `parseAsJson<T>()` gives TypeScript a claim rather than checking a runtime schema. Updates replace browser history by default; choose `history: 'push'` only when Back should step through the state changes. Next.js updates are shallow by default, so Server Components and `getServerSideProps` are not notified unless `shallow: false` is set. URL writes are batched and throttled, with a 50 ms floor and stricter Safari behavior, while hook state updates immediately. Await the setter's promise when code must observe the flushed URL. Query strings remain public input, so do not place secrets there and validate server-side before using values in database or authorization decisions.
Patterns
Wrap a Next.js App Router treeinstall-next-adapter
import { NuqsAdapter } from 'nuqs/adapters/next/app'
import type { ReactNode } from 'react'
export default function RootLayout({ children }: { children: ReactNode }) {
return <html><body><NuqsAdapter>{children}</NuqsAdapter></body></html>
}Next.js Pages Router uses `nuqs/adapters/next/pages`; choosing the wrong adapter breaks router synchronization.
Bind an input to a query parameterstore-string-param
'use client'
import { useQueryState } from 'nuqs'
const [query, setQuery] = useQueryState('q')
return <input value={query ?? ''} onChange={(event) => setQuery(event.target.value)} />Without a parser or default, the value is string or null; an empty query value is the empty string, not null.
Parse an integer with a defaultparse-integer-default
import { parseAsInteger, useQueryState } from 'nuqs'
const [page, setPage] = useQueryState(
'page',
parseAsInteger.withDefault(1)
)The default removes null from the TypeScript result but is not written to the URL when the key is absent.
Remove a key from the URLremove-query-param
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1))
await setPage(null)Setting null deletes the key and returns state to the configured default, if any.
Make Back navigate state changespush-browser-history
const [tab, setTab] = useQueryState('tab', { history: 'push' })
setTab('billing')History mode is replace by default; push can flood browser history when used for every keystroke.
Re-render Next.js server contentnotify-next-server
const [isPending, startTransition] = useTransition()
const [query, setQuery] = useQueryState(
'q',
parseAsString.withOptions({ shallow: false, startTransition })
)shallow defaults to true. Setting false causes server work, so debounce or throttle high-frequency input.
Throttle high-frequency URL writesthrottle-url-updates
const [value, setValue] = useQueryState('value', {
throttleMs: 340,
})React state changes immediately while the URL update is queued; values below the 50 ms floor are ignored.
Batch related query keysupdate-related-params
const [coords, setCoords] = useQueryStates({
lat: parseAsFloat.withDefault(45.18),
lng: parseAsFloat.withDefault(5.72),
})
await setCoords({ lat: 51.5, lng: -0.12 })The setter accepts partial updates and resolves with URLSearchParams after the batched URL flush.
Define a validated custom codeccreate-custom-parser
import { createParser } from 'nuqs'
const parseAsPort = createParser({
parse(value) {
const port = Number(value)
return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : null
},
serialize: String,
})Return null for invalid input so callers can fall back to null or a `.withDefault()` value.
Load typed values outside a hookparse-server-params
import { createLoader, parseAsInteger, parseAsString } from 'nuqs/server'
const loadSearch = createLoader({
q: parseAsString,
page: parseAsInteger.withDefault(1),
})
const values = loadSearch('?q=printer&page=2')Loaders accept several URL-like inputs, but parsed values still need business-rule validation.
Build a typed link queryserialize-link-params
import { createSerializer, parseAsInteger, parseAsString } from 'nuqs/server'
const serialize = createSerializer({ q: parseAsString, page: parseAsInteger })
const href = serialize('/search?view=grid', { q: 'laser printer', page: 2 })Passing null removes an existing key; omitted properties leave the base query unchanged.
Test with the router-free adaptertest-query-state
import { render } from '@testing-library/react'
import { withNuqsTestingAdapter } from 'nuqs/adapters/testing'
const onUrlUpdate = vi.fn()
render(<Filters />, {
wrapper: withNuqsTestingAdapter({
searchParams: '?page=2',
onUrlUpdate,
}),
})The testing adapter reports URL updates without requiring the application's real framework router.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| use-query-params | npm | Choose it for a mature hook and codec model when its supported router adapters already match your app |
| query-string | npm | Choose it when framework-independent parsing and stringifying is enough and React state synchronization is unwanted |
| react-router-dom | npm | Choose its built-in useSearchParams when a React Router app needs only strings and simple manual conversion |