nuqs is a type-safe URL state management library for Next.js and React that treats searchParams as first-class React state, with parsers for numbers, dates, arrays, and enums, plus a server-side cache that lets Server Components read the same values the client hooks write. In the Next.js 16 App Router, nuqs 2.x integrates through a small NuqsAdapter provider and replaces most of the ad-hoc useRouter().replace() plus URLSearchParams code I used to write for filters, sorts, pagination, and open-modal state, with proper TypeScript inference and zero waterfalls.
nuqs 2.x is the React state library for URL search params, officially supporting the Next.js 16 App Router, Pages Router, and React Router 7.
Wrap the app in <NuqsAdapter> once, then use useQueryState (single key) or useQueryStates (multiple keys) with parsers like parseAsInteger, parseAsStringEnum, and parseAsArrayOf.
Server Components read the same values via createSearchParamsCache, so filters render correctly on first paint without a client roundtrip.
Shallow routing is on by default. Flip shallow: false to force a Server Component refetch, and combine with throttleMs to keep debounced search inputs from spamming the network.
URL state survives refreshes, deep links, and back/forward, which is why it belongs in the URL for anything users would reasonably want to share or bookmark.
What is nuqs and why not just useState?
The short answer? Because useState disappears on refresh. And honestly, half the state on a real product page (filters, sort order, pagination cursor, which tab is open, which row is selected in a table) is exactly the kind of thing users expect to survive a hard reload, be shareable via URL, and show up correctly in the browser's back button. I've watched QA teams paste filter combinations straight into the URL bar too many times to think otherwise.
The problem is that hand-rolling URLSearchParams plus router.replace() plus JSON parsing plus type coercion for every filter is tedious and error-prone. nuqs collapses that into a hook that looks and feels like useState, but the value is backed by the URL. The library was created by François Best, is developed in the open on GitHub (47ng/nuqs), and has become the go-to choice for URL state in App Router apps. It ships type-safe parsers, server-side reading via a cache API, batched updates, throttling, and shallow-vs-full routing controls.
In practice, I reach for nuqs whenever a piece of state answers "yes" to any of these: should it be bookmarkable, should it deep-link, should hitting back restore it, or should a Server Component see it on first render? Everything else (hover state, temporary form drafts, animation state) stays in useState.
Install and set up nuqs in Next.js 16
The install is one package plus a provider. Version 2.x is what you want for Next.js 16. It's the release that fixed the async searchParams compatibility and dropped the legacy adapter that used to sit in nuqs/adapters/next.
pnpm add nuqs
Wrap the App Router tree in NuqsAdapter. This component is a Client Component but it renders no DOM. It just wires the hooks up to the correct router integration. Put it as close to the root as possible so any deep tree can call the hooks.
// app/layout.tsx
import { NuqsAdapter } from 'nuqs/adapters/next/app'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<NuqsAdapter>{children}</NuqsAdapter>
</body>
</html>
)
}
Two things to double-check before moving on. First, the import path is nuqs/adapters/next/app for the App Router. The Pages Router adapter is a different export and will silently do nothing if you cross the streams. Second, if you're using the Pages Router in the same project (rare, but it happens during migrations), you'll need to conditionally load the correct adapter per route. See our Pages Router to App Router migration playbook for the mixed-project pattern I've been running in production.
Basic usage: useQueryState with parsers
The primary hook is useQueryState. Called with a key name, it returns a [value, setValue] tuple whose shape is inferred from the parser you pass. Without a parser the value is string | null, which is fine for text fields but almost never what you want for anything else.
Three things worth calling out from that snippet. The default value from .withDefault() makes the return type non-nullable, which is the reason I recommend always providing one; it saves you a null check on every read. Passing null to the setter removes the key from the URL, which is how you implement "reset" cleanly. And parseAsStringEnum gives you TypeScript-level exhaustiveness: pass a value not in the list and the URL simply doesn't update.
The full parser list in nuqs 2.x covers parseAsString, parseAsInteger, parseAsFloat, parseAsBoolean, parseAsIsoDateTime, parseAsTimestamp, parseAsStringEnum, parseAsStringLiteral, parseAsNumberLiteral, parseAsJson, and combinators parseAsArrayOf for building lists. All of them are composable via .withDefault(), .withOptions(), and .withCustomSerializer().
Managing multiple keys with useQueryStates
If you're touching multiple URL keys in one interaction, use useQueryStates. It takes a parser object and returns a single state object plus a batched setter, meaning updating three filters at once results in one URL update, not three router pushes fighting over the same tick.
The pattern I've settled on is defining the parser object once as a module-level constant (see filterParsers above), then importing it in every component that needs to read or write those filters. That way the client toolbar, the Server Component page, and any URL builder in a "share this filter" button all share a single source of truth for the schema.
Reading nuqs values in Server Components
This is where nuqs really pulls its weight in the App Router. In Next.js 16, page.tsx receives searchParams as a Promise, per the official Next.js page.tsx reference. I covered that async change in more detail in the Next.js searchParams async parsing guide. nuqs gives you createSearchParamsCache, which parses that promise once per request and hands you the same typed values you get on the client.
The trick? productSearchParams is the same schema the client hook uses. You just import from nuqs/server instead of nuqs in the Server Component. If you change the schema in one place, TypeScript catches the drift in the other. This eliminates the class of bug where the client and server disagree on what a filter means. (I hit that exact drift on a client project last spring; three-hour bug that would have been zero with a shared parser file.)
Shallow routing, throttling, and history mode
Every nuqs update accepts an options object (or takes it from the hook-level options). Three flags account for 90% of the tuning I do in production.
shallow (default: true)
When shallow: true, nuqs updates the URL client-side without triggering a Server Component re-render. That's the right default for state that only affects client UI (a "which tab is open" toggle). For filters that need the server to refetch data, set shallow: false. It uses router.replace() or router.push() under the hood and triggers the RSC payload fetch.
history ('replace' | 'push')
Default is 'replace', which does not add a back-button entry. Use 'push' when a user action is significant enough that hitting Back should undo it, like applying a new filter, opening a modal, or changing the visible page. Rapid updates (typing in a search box) should stay on 'replace' so the back button doesn't fill up with 40 intermediate keystrokes.
throttleMs (default: 50)
The minimum interval between URL updates. If you set state 10 times in a tick, nuqs coalesces them into one router call. For debounced search-as-you-type, I usually bump this to 300 or 500 so we don't shell out to the network on every keystroke, then combine it with shallow: false for the server refetch.
A real example: debounced product search
Putting it all together: a search input that keeps the URL as the source of truth, throttles typing, and re-runs the server-side query when the user pauses. This is the pattern I ship for storefronts and admin dashboards.
'use client'
import { useQueryState, parseAsString } from 'nuqs'
import { useEffect, useState } from 'react'
export function ProductSearchInput() {
const [query, setQuery] = useQueryState(
'q',
parseAsString.withDefault('').withOptions({
shallow: false,
throttleMs: 400,
history: 'replace',
}),
)
// Local state for the input so typing feels instant while
// the debounced URL sync happens in the background.
const [local, setLocal] = useState(query)
useEffect(() => {
const t = setTimeout(() => setQuery(local || null), 400)
return () => clearTimeout(t)
}, [local, setQuery])
return (
<input
type="search"
value={local}
onChange={(e) => setLocal(e.target.value)}
placeholder="Search products..."
className="w-full rounded border px-3 py-2"
/>
)
}
The pattern splits input responsiveness from URL persistence: the local useState keeps typing at 60fps, and the URL updates on a 400ms trailing debounce. Because shallow: false, each URL commit triggers the Server Component to re-run its database query, then streams the new grid in via Suspense. Wire this alongside a Suspense boundary and a skeleton, following the same streaming approach as the Next.js Forms with React Hook Form and Zod playbook for the loading UX.
Common pitfalls and how to avoid them
Hydration mismatch after a hard reload
If you render a value from useQueryState during SSR and don't provide a default, the initial paint sees null and the client sees the URL value, causing hydration warnings. Always call .withDefault() or use the server-parsed value from createSearchParamsCache.
Server Components not re-rendering on filter change
This is always the shallow: true default. Filter hooks that should refetch data need shallow: false either on the parser (.withOptions({ shallow: false })) or in the hook's options argument.
The URL is huge because arrays serialize weirdly
Default serialization for parseAsArrayOf(parseAsString) is comma-separated. If your array values themselves contain commas, use parseAsArrayOf(parseAsString, '|') to pick a different separator, or move to parseAsJson with a Zod schema for anything genuinely nested.
useQueryState only works inside NuqsAdapter
Forgetting the provider gives you a runtime error that reads like "no router context found." Wrap the layout, not just the leaf. Same trap as forgetting a Provider for any React context library.
Frequently Asked Questions
Is nuqs compatible with Next.js 16 App Router?
Yes. nuqs 2.x officially supports Next.js 14, 15, and 16 App Router, including the async searchParams promise introduced in Next.js 15. Use NuqsAdapter from nuqs/adapters/next/app for App Router routes.
Can I use nuqs in Server Components?
Not the hooks; those are Client Components only. But createSearchParamsCache from nuqs/server lets Server Components parse the same URL search params with the same schema, so you get end-to-end type safety without duplicating parsers.
What is the difference between useQueryState and useQueryStates?
useQueryState manages a single key and returns a tuple like useState. useQueryStates takes an object of parsers and manages multiple keys with a single batched setter, which is the right choice whenever one interaction updates more than one URL parameter.
How do I debounce nuqs updates for a search input?
Combine local useState for the input value with a setTimeout effect that writes to nuqs after the user pauses typing. Set throttleMs to your debounce interval and history: 'replace' so the back button doesn't fill up with intermediate values.
Should I use nuqs or Zustand for global state?
Different tools. Use nuqs for state that should live in the URL: filters, pagination, tab selection, deep-linkable modals. Use Zustand (or Jotai, Redux, React Context) for state that should not survive a refresh or be shareable, like optimistic updates, form drafts, or in-memory caches.
Hannah is a performance-focused frontend engineer with nine years building React applications, the last five exclusively on Next.js. She spent three years at Spotify on the Web Player team optimizing TTI on low-end devices in emerging markets, then two years at a Stockholm-based e-commerce platform where she rebuilt the storefront on App Router and edge runtime, hitting sub-200ms TTFB across European POPs.
She currently freelances for DTC brands and SaaS companies that need a Core Web Vitals rescue, and occasionally contributes patches to next/font and the Vercel Speed Insights SDK. She has presented at React Advanced London and React Summit Amsterdam on streaming rendering patterns.
Her tutorials are the ones with Lighthouse screenshots and flame graphs, because she doesn't trust benchmarks she can't reproduce. Strong preference for Playwright over Cypress and Vitest over Jest.
Ship Next.js 16 on Cloudflare Workers with the @opennextjs/cloudflare adapter. Bindings, KV cache, images, Postgres pooling, and honest cost math vs Vercel.
The Next.js preload pattern uses React cache() and a void preload() helper to eliminate server component fetch waterfalls. Learn the DAL setup, Suspense streaming, and ORM memoization tricks that shave 300-900ms off real routes.
Server-side feature flags in Next.js 16 without client bundles or flash-of-wrong-variant. Walk through install, Edge Config storage, precomputation for static routes, percentage rollouts, kill switches, and how the SDK compares to LaunchDarkly, Statsig, and GrowthBook.