nuqs with Next.js 16: Type-Safe URL State Management for App Router (2026)

Set up nuqs 2.x in Next.js 16 for type-safe URL state. Parsers, useQueryState, Server Component cache, and a real debounced product search example.

nuqs Next.js 16 URL State Guide (2026)

Updated: September 16, 2026

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.

'use client'

import { useQueryState, parseAsInteger, parseAsStringEnum } from 'nuqs'

type SortOrder = 'newest' | 'oldest' | 'popular'

export function ProductToolbar() {
  const [page, setPage] = useQueryState(
    'page',
    parseAsInteger.withDefault(1)
  )

  const [sort, setSort] = useQueryState(
    'sort',
    parseAsStringEnum<SortOrder>(['newest', 'oldest', 'popular']).withDefault('newest')
  )

  return (
    <div className="flex gap-3">
      <select value={sort} onChange={(e) => setSort(e.target.value as SortOrder)}>
        <option value="newest">Newest</option>
        <option value="oldest">Oldest</option>
        <option value="popular">Most popular</option>
      </select>

      <button onClick={() => setPage((p) => p + 1)}>Next page ({page})</button>
      <button onClick={() => setPage(null)}>Reset</button>
    </div>
  )
}

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.

'use client'

import {
  useQueryStates,
  parseAsInteger,
  parseAsString,
  parseAsArrayOf,
  parseAsBoolean,
} from 'nuqs'

const filterParsers = {
  q: parseAsString.withDefault(''),
  categories: parseAsArrayOf(parseAsString).withDefault([]),
  minPrice: parseAsInteger.withDefault(0),
  maxPrice: parseAsInteger.withDefault(10_000),
  inStock: parseAsBoolean.withDefault(false),
}

export function ProductFilters() {
  const [filters, setFilters] = useQueryStates(filterParsers, {
    history: 'push',
    shallow: false, // trigger Server Component re-render
  })

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault()
        const data = new FormData(e.currentTarget)
        setFilters({
          q: data.get('q') as string,
          categories: data.getAll('categories') as string[],
          minPrice: Number(data.get('minPrice')),
          maxPrice: Number(data.get('maxPrice')),
          inStock: data.get('inStock') === 'on',
        })
      }}
    >
      {/* form fields */}
    </form>
  )
}

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.

// app/products/search-params.ts
import { createSearchParamsCache } from 'nuqs/server'
import {
  parseAsInteger,
  parseAsString,
  parseAsArrayOf,
  parseAsBoolean,
} from 'nuqs/server'

export const productSearchParams = createSearchParamsCache({
  q: parseAsString.withDefault(''),
  categories: parseAsArrayOf(parseAsString).withDefault([]),
  minPrice: parseAsInteger.withDefault(0),
  maxPrice: parseAsInteger.withDefault(10_000),
  inStock: parseAsBoolean.withDefault(false),
  page: parseAsInteger.withDefault(1),
})
// app/products/page.tsx
import { productSearchParams } from './search-params'
import { ProductFilters } from './product-filters'
import { ProductGrid } from './product-grid'

type PageProps = {
  searchParams: Promise<Record<string, string | string[] | undefined>>
}

export default async function ProductsPage({ searchParams }: PageProps) {
  const params = await productSearchParams.parse(searchParams)

  const products = await db.products.findMany({
    where: {
      name: { contains: params.q },
      category: params.categories.length ? { in: params.categories } : undefined,
      price: { gte: params.minPrice, lte: params.maxPrice },
      inStock: params.inStock || undefined,
    },
    skip: (params.page - 1) * 20,
    take: 20,
  })

  return (
    <section>
      <ProductFilters />
      <ProductGrid products={products} />
    </section>
  )
}

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.

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.

About the Author Hannah Lindqvist

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.