The URL is the most underused state container in your app. It is shareable, bookmarkable, survives refreshes, and works with the browser back button for free. nuqs lets you read and write that state with the exact same ergonomics as useState — but fully type-safe and synced to the query string. In this tutorial you will build a real product dashboard with filters, pagination, and sorting, all driven entirely by the URL.
What You'll Build
We will build a product catalog dashboard where every piece of UI state lives in the URL:
- A debounced search box that updates
?q= - Category filters stored as an array in
?categories= - Pagination with
?page=and?perPage= - Sortable columns with
?sort=and?dir= - A fully shareable link — copy the URL and a colleague sees the exact same filtered view
- Server-side parsing so the first render is already filtered, with zero layout shift
By the end you will understand every core nuqs API: useQueryState, useQueryStates, the parser library, options like history, shallow, and throttleMs, and server-side caches with createSearchParamsCache.
Prerequisites
Before starting, ensure you have:
- Node.js 20+ installed
- A Next.js 15 project using the App Router
- Working knowledge of React hooks and TypeScript
- Familiarity with
useState(because that is the mental modelnuqsborrows)
Why URL State?
Most React apps store filter and pagination state in useState. It works until someone refreshes the page and loses their filters, or tries to share a link and the recipient sees an empty default view. The URL solves all of this — but parsing query strings by hand is tedious and error-prone:
// The old, fragile way
const page = Number(searchParams.get('page') ?? '1')
const categories = searchParams.get('categories')?.split(',') ?? []
// ...now serialize it all back manually on every change 😩nuqs replaces this with a typed, declarative API. Think of it as useState, except the source of truth is the query string and every value is validated through a parser.
Step 1: Installation and Adapter Setup
Install the package:
npm install nuqsnuqs is framework-agnostic, so you tell it which router you are on via an adapter. For the Next.js App Router, wrap your app in NuqsAdapter. The cleanest place is the root layout:
// 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>
)
}That single provider is all the wiring nuqs needs. There are dedicated adapters for the Pages Router, React Router, Remix, and TanStack Router too — only the import path changes.
Step 2: Your First Query State
Let's add the search box. The useQueryState hook mirrors useState exactly — it returns a value and a setter:
// app/products/search-box.tsx
'use client'
import { useQueryState } from 'nuqs'
export function SearchBox() {
const [query, setQuery] = useQueryState('q')
return (
<input
value={query ?? ''}
onChange={(e) => setQuery(e.target.value || null)}
placeholder="Search products..."
/>
)
}Type a few characters and watch the URL become ?q=keyboard. Refresh the page — the input keeps its value. Setting the state to null removes the parameter from the URL entirely, which is how you express "back to default".
By default query is typed as string | null. The null means "this parameter is absent". We will fix the nullability with a default value next.
Step 3: Parsers and Default Values
A raw query string is always text. Parsers convert that text into real types and back. nuqs ships parsers for every common case:
'use client'
import {
useQueryState,
parseAsInteger,
parseAsString,
parseAsBoolean,
} from 'nuqs'
export function Pagination() {
// parseAsInteger.withDefault(1) => page is `number`, never null
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1))
return (
<div>
<button onClick={() => setPage((p) => p - 1)} disabled={page <= 1}>
Previous
</button>
<span>Page {page}</span>
<button onClick={() => setPage((p) => p + 1)}>Next</button>
</div>
)
}Two things to notice:
parseAsIntegermakespagea realnumber. CallingsetPage((p) => p + 1)works with the functional updater, just likeuseState..withDefault(1)removes thenullfrom the type. When?page=is missing, you get1instead ofnull, andnuqskeeps the URL clean by omitting the default value rather than writing?page=1.
The built-in parser library covers nearly everything:
| Parser | Type | Example URL |
|---|---|---|
parseAsString | string | ?q=keyboard |
parseAsInteger | number | ?page=2 |
parseAsFloat | number | ?price=19.99 |
parseAsBoolean | boolean | ?inStock=true |
parseAsArrayOf(parseAsString) | string[] | ?tags=a,b,c |
parseAsStringLiteral([...]) | union | ?dir=asc |
parseAsIsoDateTime | Date | ?from=2026-06-03... |
If a user hand-edits the URL to something invalid (?page=banana), the parser fails gracefully and falls back to your default — no crashes.
Step 4: Arrays for Multi-Select Filters
Category filters are a multi-select, so we store them as an array. Combine parseAsArrayOf with parseAsString:
// app/products/category-filter.tsx
'use client'
import { useQueryState, parseAsArrayOf, parseAsString } from 'nuqs'
const ALL_CATEGORIES = ['keyboards', 'mice', 'monitors', 'audio']
export function CategoryFilter() {
const [categories, setCategories] = useQueryState(
'categories',
parseAsArrayOf(parseAsString).withDefault([]),
)
function toggle(cat: string) {
setCategories((current) =>
current.includes(cat)
? current.filter((c) => c !== cat)
: [...current, cat],
)
}
return (
<fieldset>
{ALL_CATEGORIES.map((cat) => (
<label key={cat}>
<input
type="checkbox"
checked={categories.includes(cat)}
onChange={() => toggle(cat)}
/>
{cat}
</label>
))}
</fieldset>
)
}The URL now reads ?categories=keyboards,mice. By default the separator is a comma, but you can change it with .withOptions if your values contain commas:
parseAsArrayOf(parseAsString, ';').withDefault([])
// produces ?categories=keyboards;miceStep 5: Grouping Related State with useQueryStates
Sorting needs two values that should always change together: a column and a direction. Updating them in two separate useQueryState calls would trigger two URL writes. useQueryStates batches a group of parameters into a single update:
// app/products/sort-control.tsx
'use client'
import { useQueryStates, parseAsStringLiteral } from 'nuqs'
const columns = ['name', 'price', 'rating'] as const
const directions = ['asc', 'desc'] as const
export function SortControl() {
const [sort, setSort] = useQueryStates({
sort: parseAsStringLiteral(columns).withDefault('name'),
dir: parseAsStringLiteral(directions).withDefault('asc'),
})
function sortBy(column: (typeof columns)[number]) {
setSort((prev) => ({
sort: column,
// toggle direction if the same column is clicked again
dir: prev.sort === column && prev.dir === 'asc' ? 'desc' : 'asc',
}))
}
return (
<div>
{columns.map((col) => (
<button key={col} onClick={() => sortBy(col)}>
{col} {sort.sort === col ? (sort.dir === 'asc' ? '↑' : '↓') : ''}
</button>
))}
</div>
)
}parseAsStringLiteral is the secret weapon here: it constrains sort to exactly 'name' | 'price' | 'rating' at the type level. An invalid value in the URL falls back to the default, so your switch statements are always exhaustive and safe.
Step 6: Options — history, shallow, and throttling
Every parser accepts options through .withOptions (or as a third argument). These three matter most in real apps:
'use client'
import { useQueryState, parseAsString } from 'nuqs'
export function SearchBox() {
const [query, setQuery] = useQueryState(
'q',
parseAsString.withDefault('').withOptions({
// push a new browser history entry per change (back button steps through searches)
history: 'push',
// throttle URL writes so fast typing does not flood history
throttleMs: 300,
}),
)
// ...
}history — defaults to 'replace' (the URL changes without adding a back-button entry). Use 'push' when each state change is a distinct "page" the user should be able to navigate back to.
throttleMs — coalesces rapid updates. For a search box bound to onChange, a throttle of 200–500ms keeps the URL from updating on every keystroke. This replaces most manual debounce logic.
shallow — the big one for data fetching. By default nuqs updates are client-side only (shallow: true): React Server Components do not re-run. When you need the server to re-render with the new params (for example, to refetch data in an RSC), set shallow: false:
parseAsInteger.withDefault(1).withOptions({ shallow: false })With shallow: false, changing ?page= triggers a server round-trip and your Server Component reads the new value automatically.
Step 7: Smooth Updates with startTransition
When shallow: false triggers server work, you want a pending UI instead of a frozen page. nuqs integrates with React's useTransition:
'use client'
import { useTransition } from 'react'
import { useQueryState, parseAsInteger } from 'nuqs'
export function Pagination() {
const [isLoading, startTransition] = useTransition()
const [page, setPage] = useQueryState(
'page',
parseAsInteger.withDefault(1).withOptions({
shallow: false,
startTransition,
}),
)
return (
<div data-pending={isLoading ? '' : undefined}>
<button onClick={() => setPage((p) => p + 1)}>Next</button>
{isLoading && <span>Loading...</span>}
</div>
)
}Now isLoading is true while the server refetches, letting you dim the table or show a spinner — no janky blank states.
Step 8: Server-Side Parsing with createSearchParamsCache
Here is where nuqs truly shines. To render the first page already filtered, parse the same params on the server. The trick is to share parser definitions between client and server.
First, define parsers in one place:
// app/products/search-params.ts
import {
parseAsInteger,
parseAsString,
parseAsArrayOf,
parseAsStringLiteral,
createSearchParamsCache,
} from 'nuqs/server'
export const productSearchParams = {
q: parseAsString.withDefault(''),
page: parseAsInteger.withDefault(1),
categories: parseAsArrayOf(parseAsString).withDefault([]),
sort: parseAsStringLiteral(['name', 'price', 'rating']).withDefault('name'),
dir: parseAsStringLiteral(['asc', 'desc']).withDefault('asc'),
}
export const searchParamsCache = createSearchParamsCache(productSearchParams)Now your Server Component can parse the incoming params with full type-safety:
// app/products/page.tsx
import { searchParamsCache } from './search-params'
import { getProducts } from '@/lib/products'
import { ProductTable } from './product-table'
export default async function ProductsPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
// parse() validates and caches the typed values for this request
const { q, page, categories, sort, dir } = await searchParamsCache.parse(
await searchParams,
)
const products = await getProducts({ q, page, categories, sort, dir })
return <ProductTable products={products} />
}The client components reuse the exact same parser object, so there is a single source of truth. Pass productSearchParams straight into useQueryStates:
'use client'
import { useQueryStates } from 'nuqs'
import { productSearchParams } from './search-params'
export function Filters() {
const [state, setState] = useQueryStates(productSearchParams)
// state is fully typed, identical to the server-parsed shape
// ...
}Edit a filter on the client, the URL updates, shallow: false triggers a server render, and ProductsPage refetches with the new params. One definition, both sides, fully type-safe.
Step 9: Building Shareable Links and Serializers
Sometimes you need to build a URL without rendering a component — a "Reset filters" link or a server-generated email. nuqs exposes a createSerializer for exactly this:
// app/products/search-params.ts (continued)
import { createSerializer } from 'nuqs/server'
export const serialize = createSerializer(productSearchParams)import Link from 'next/link'
import { serialize } from './search-params'
// produces /products?categories=keyboards&sort=price&dir=desc
const url = serialize('/products', {
categories: ['keyboards'],
sort: 'price',
dir: 'desc',
})
export function CheapKeyboardsLink() {
return <Link href={url}>Cheapest keyboards</Link>
}Because the serializer uses the same parsers, the generated link is guaranteed to be parsed back into the identical typed state. Default values are omitted automatically, keeping links short.
Testing Your Implementation
Verify the behavior end to end:
- Filter persistence — apply a search term and two categories, then refresh. The filters and results should be identical.
- Shareable state — copy the URL into a new browser tab. You should land on the exact filtered view, server-rendered.
- Back button — with
history: 'push', run three searches and press back. You should step backward through each one. - Invalid input — manually set
?page=bananain the address bar. The page should fall back to page 1 without errors. - No layout shift — disable JavaScript and load a filtered URL. Because parsing happens on the server, the correct results render immediately.
Troubleshooting
"useQueryState must be used within a NuqsAdapter" — you forgot to wrap your tree in NuqsAdapter, or imported the wrong adapter for your router.
The server does not re-render on change — you are using the default shallow: true. Add .withOptions({ shallow: false }) to the parsers that should trigger server work.
Default values appear in the URL — make sure you use .withDefault() rather than passing a default through useState-style logic. nuqs only omits a value from the URL when it knows the default via the parser.
Type errors with searchParams — in Next.js 15, searchParams is a Promise. Remember to await it before passing to searchParamsCache.parse().
Next Steps
- Combine
nuqswith TanStack Table for fully URL-driven data grids — sorting, pagination, and column filters all in the query string. - Pair it with TanStack Query so a URL change automatically refetches the right data.
- Explore
parseAsJsonwith a Zod schema to store complex structured state safely in the URL. - Read our related tutorials on Zustand for client state and Jotai's atomic model to understand when URL state, global state, and atomic state each fit best.
Conclusion
nuqs turns the URL into a first-class state container with the ergonomics of useState and the safety of TypeScript. You learned how to read and write single values with useQueryState, group related ones with useQueryStates, validate everything through parsers, tune behavior with history, shallow, and throttleMs, and parse the same params on the server with createSearchParamsCache for instant, shareable, refresh-proof views.
The pattern is simple but powerful: if a piece of state should be shareable or survive a refresh, it belongs in the URL — and nuqs makes putting it there a one-line change.