Every data table, search page, and filtered list has the same problem: the user applies a filter, the results update, they share the URL — and the recipient lands on an empty page because the filter lived in useState and died with the component.
nuqs solves this with a useState-compatible API where the URL is the state container.
What nuqs Does
// Regular state — lost on reload, not shareable
const [search, setSearch] = useState('')
// URL state — survives reload, shareable, bookmarkable
const [search, setSearch] = useQueryState('q')
Type a few characters and the URL becomes ?q=keyboard. Refresh — the input still has its value. Copy the URL — someone else sees the same filtered results.
Setup
npm install nuqs
// app/layout.tsx
import { NuqsAdapter } from 'nuqs/adapters/next/app'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<NuqsAdapter>{children}</NuqsAdapter>
</body>
</html>
)
}
useQueryState: Single Parameter
'use client'
import { useQueryState } from 'nuqs'
export function SearchInput() {
const [query, setQuery] = useQueryState('q')
return (
<input
value={query ?? ''}
onChange={(e) => setQuery(e.target.value || null)}
placeholder="Search..."
/>
)
}
Setting the value to null removes the parameter from the URL entirely — the equivalent of "clear this filter."
Parsers: Type Safety Beyond Strings
import {
parseAsInteger, parseAsFloat, parseAsBoolean,
parseAsArrayOf, parseAsString, parseAsStringEnum, parseAsIsoDate,
} from 'nuqs'
// Page number — URL: ?page=3
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1))
// Multi-select — URL: ?tags=react,typescript
const [tags, setTags] = useQueryState('tags', parseAsArrayOf(parseAsString).withDefault([]))
// Enum — URL: ?sort=price_asc
const [sort, setSort] = useQueryState(
'sort',
parseAsStringEnum(['name_asc', 'name_desc', 'price_asc', 'price_desc'] as const).withDefault('name_asc')
)
// Date — URL: ?from=2026-01-01
const [from, setFrom] = useQueryState('from', parseAsIsoDate)
.withDefault(value) means the hook returns the default when the parameter is absent, instead of null.
useQueryStates: Multiple Parameters Together
When parameters belong together, update them atomically:
import { useQueryStates, parseAsInteger, parseAsString, parseAsStringEnum } from 'nuqs'
export function useTableState() {
return useQueryStates({
page: parseAsInteger.withDefault(1),
perPage: parseAsInteger.withDefault(20),
q: parseAsString.withDefault(''),
sortBy: parseAsStringEnum(['name', 'email', 'createdAt'] as const).withDefault('createdAt'),
sortDir: parseAsStringEnum(['asc', 'desc'] as const).withDefault('desc'),
})
}
function UsersTable() {
const [{ page, perPage, q, sortBy, sortDir }, setTableState] = useTableState()
function handleSort(field: 'name' | 'email' | 'createdAt') {
setTableState({
sortBy: field,
sortDir: sortBy === field && sortDir === 'asc' ? 'desc' : 'asc',
page: 1, // reset page on sort change
})
}
}
setTableState accepts a partial object — only specified keys update.
Navigation Options
// Trigger full server re-render (re-fetches Server Components)
setValue(value, { shallow: false })
// Push to history (back button returns to previous filter state)
setValue(value, { history: 'push' })
// Throttle URL writes (useful for live search inputs)
setValue(value, { throttleMs: 200 })
shallow: false is critical for App Router: it makes the URL update trigger a real navigation so Server Components that read searchParams get the new values.
Server Component Integration
// lib/search-params.ts
import { createSearchParamsCache, parseAsInteger, parseAsString } from 'nuqs/server'
export const searchParamsCache = createSearchParamsCache({
page: parseAsInteger.withDefault(1),
perPage: parseAsInteger.withDefault(20),
q: parseAsString.withDefault(''),
status: parseAsStringEnum(['active', 'inactive', 'all'] as const).withDefault('all'),
})
// app/users/page.tsx (Server Component)
import { searchParamsCache } from '@/lib/search-params'
export default async function UsersPage({ searchParams }) {
const { page, perPage, q, status } = searchParamsCache.parse(await searchParams)
const users = await db.query.users.findMany({
where: and(
q ? ilike(users.name, `%${q}%`) : undefined,
status !== 'all' ? eq(users.status, status) : undefined,
),
offset: (page - 1) * perPage,
limit: perPage,
})
return <UsersTable initialUsers={users} />
}
The same parsers serve both server (DB query) and client (filter controls).
Real Pattern: Filters with startTransition
'use client'
export function UserFilters() {
const [isPending, startTransition] = useTransition()
const [filters, setFilters] = useQueryStates({
q: parseAsString.withDefault(''),
status: parseAsStringEnum(['all', 'active', 'inactive'] as const).withDefault('all'),
page: parseAsInteger.withDefault(1),
})
function updateFilter(key: keyof typeof filters, value: any) {
startTransition(() => {
setFilters({ [key]: value, page: 1 }, { shallow: false })
})
}
return (
<div className={cn('flex gap-3', isPending && 'opacity-60 pointer-events-none')}>
<Input
value={filters.q}
onChange={(e) => updateFilter('q', e.target.value || '')}
placeholder="Search..."
/>
<Select value={filters.status} onValueChange={(v) => updateFilter('status', v)}>
<SelectTrigger className="w-36"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
</SelectContent>
</Select>
{(filters.q || filters.status !== 'all') && (
<Button variant="ghost" size="sm"
onClick={() => setFilters({ q: '', status: 'all', page: 1 }, { shallow: false })}>
Clear
</Button>
)}
</div>
)
}
Quick Reference
// Setup
<NuqsAdapter>{children}</NuqsAdapter>
// Single param
const [value, setValue] = useQueryState('key', parser.withDefault(default))
// Multiple params (atomic)
const [state, setState] = useQueryStates({ key1: parser1, key2: parser2 })
// Common parsers
parseAsString | parseAsInteger | parseAsFloat | parseAsBoolean
parseAsArrayOf(p) | parseAsStringEnum([]) | parseAsIsoDate | parseAsJson(schema)
// Options
setValue(v, { shallow: false }) // trigger server re-render
setValue(v, { history: 'push' }) // add to history
setValue(v, { throttleMs: 300 }) // debounce
// Server
import { createSearchParamsCache } from 'nuqs/server'
const cache = createSearchParamsCache({ page: parseAsInteger.withDefault(1) })
const { page } = cache.parse(await searchParams)
Full article at stacknotice.com/blog/nuqs-nextjs-url-state-2026
Top comments (0)