nuqs
npx machina-cli add skill noklip-io/agent-skills/nuqs --openclawnuqs Best Practices
Type-safe URL query state management for React. Like useState, but stored in the URL.
Setup (Required First)
Wrap your app with the appropriate adapter:
// Next.js App Router - app/layout.tsx
import { NuqsAdapter } from 'nuqs/adapters/next/app'
export default function RootLayout({ children }) {
return <NuqsAdapter>{children}</NuqsAdapter>
}
// Next.js Pages Router - pages/_app.tsx
import { NuqsAdapter } from 'nuqs/adapters/next/pages'
// React SPA (Vite/CRA)
import { NuqsAdapter } from 'nuqs/adapters/react'
// Remix - app/root.tsx
import { NuqsAdapter } from 'nuqs/adapters/remix'
// React Router v6/v7
import { NuqsAdapter } from 'nuqs/adapters/react-router'
Global Options
import { throttle } from 'nuqs'
<NuqsAdapter
defaultOptions={{
shallow: false, // notify server by default
scroll: true, // scroll to top on change
clearOnDefault: true, // remove param when equals default
limitUrlUpdates: throttle(250) // throttle URL updates
}}
>
{children}
</NuqsAdapter>
Core API
Single Parameter
'use client'
import { useQueryState, parseAsInteger } from 'nuqs'
// String (default) - returns null | string
const [search, setSearch] = useQueryState('q')
// With parser + default (recommended)
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1))
// Updates
setSearch('hello') // ?q=hello
setSearch(null) // removes param
setPage(p => p + 1) // functional update
await setPage(5) // returns Promise<URLSearchParams>
Multiple Parameters
import { useQueryStates, parseAsInteger, parseAsString } from 'nuqs'
const [filters, setFilters] = useQueryStates({
q: parseAsString.withDefault(''),
page: parseAsInteger.withDefault(1),
sort: parseAsString.withDefault('date')
})
// Partial updates
setFilters({ page: 1, sort: 'name' })
// Await batch update
const params = await setFilters({ page: 2 })
params.get('page') // '2'
Built-in Parsers
| Parser | Type | Example URL |
|---|---|---|
parseAsString | string | ?q=hello |
parseAsInteger | number | ?page=1 |
parseAsFloat | number | ?price=9.99 |
parseAsHex | number | ?color=ff0000 |
parseAsBoolean | boolean | ?active=true |
parseAsIsoDateTime | Date | ?date=2024-01-15T10:30:00Z |
parseAsTimestamp | Date | ?t=1705312200000 |
parseAsArrayOf(parser) | T[] | ?tags=a,b,c |
parseAsArrayOf(parser, ';') | T[] | ?ids=1;2;3 (custom separator) |
parseAsJson<T>() | T | ?data={"key":"value"} |
parseAsStringEnum(values) | enum | ?status=active |
parseAsStringLiteral(arr) | literal | ?sort=asc |
parseAsNumberLiteral(arr) | literal | ?dice=6 |
Enum & Literal Examples
// String enum
enum Status { Active = 'active', Inactive = 'inactive' }
const [status] = useQueryState('status',
parseAsStringEnum(Object.values(Status)).withDefault(Status.Active)
)
// String literal (type-safe)
const sortOptions = ['asc', 'desc'] as const
const [sort] = useQueryState('sort',
parseAsStringLiteral(sortOptions).withDefault('asc')
)
// Number literal
const diceSides = [1, 2, 3, 4, 5, 6] as const
const [dice] = useQueryState('dice',
parseAsNumberLiteral(diceSides).withDefault(1)
)
Arrays
// Default comma separator: ?tags=react,typescript,nuqs
const [tags, setTags] = useQueryState('tags',
parseAsArrayOf(parseAsString).withDefault([])
)
// Custom separator: ?ids=1;2;3
const [ids] = useQueryState('ids',
parseAsArrayOf(parseAsInteger, ';').withDefault([])
)
Options
useQueryState('key', parseAsString.withOptions({
history: 'push', // 'push' | 'replace' (default)
shallow: false, // true (default) = client only, false = notify server
scroll: false, // scroll to top on change
throttleMs: 500, // throttle URL updates (min 50ms)
clearOnDefault: true, // remove param when equals default (default: true)
startTransition, // React useTransition for loading states
}))
Options precedence: call-level > parser-level > hook-level > global adapter
// Parser-level options
const parser = parseAsString.withOptions({ shallow: false })
// Hook-level options
const [q, setQ] = useQueryState('q', parser, { history: 'push' })
// Call-level override (highest priority)
setQ('value', { shallow: true })
Functional Updates & Batching
// Functional updates
setCount(c => c + 1)
setCount(c => c * 2) // Both batched in same tick
// Chained functional updates execute in order
function onClick() {
setCount(x => x + 1) // 0 → 1
setCount(x => x * 2) // 1 → 2
}
// Await updates
const search = await setFilters({ page: 2 })
search.get('page') // '2'
Loading States with useTransition
'use client'
import { useTransition } from 'react'
import { useQueryState, parseAsString } from 'nuqs'
function Search({ results }) {
const [isLoading, startTransition] = useTransition()
const [query, setQuery] = useQueryState('q',
parseAsString.withOptions({
startTransition, // enables loading state
shallow: false // required for server updates
})
)
return (
<>
<input value={query ?? ''} onChange={e => setQuery(e.target.value)} />
{isLoading ? <Spinner /> : <Results data={results} />}
</>
)
}
Custom Parsers
Basic Custom Parser
// Simple date parser
const parseAsDate = {
parse: (value: string) => new Date(value),
serialize: (date: Date) => date.toISOString().split('T')[0]
}
const [date, setDate] = useQueryState('date', parseAsDate)
With createParser (for reference types)
For non-primitive types, provide eq function for clearOnDefault to work:
import { createParser, parseAsStringLiteral } from 'nuqs'
// Date with equality check
const parseAsDate = createParser({
parse: (value: string) => new Date(value.slice(0, 10)),
serialize: (date: Date) => date.toISOString().slice(0, 10),
eq: (a: Date, b: Date) => a.getTime() === b.getTime()
})
// Complex type (e.g., TanStack Table sort state)
// URL: ?sort=name:asc → { id: 'name', desc: false }
const parseAsSort = createParser({
parse(query) {
const [id = '', dir = ''] = query.split(':')
return { id, desc: dir === 'desc' }
},
serialize(value) {
return `${value.id}:${value.desc ? 'desc' : 'asc'}`
},
eq(a, b) {
return a.id === b.id && a.desc === b.desc
}
})
Server Components (Next.js)
// lib/searchParams.ts
import { createSearchParamsCache, parseAsInteger, parseAsString } from 'nuqs/server'
export const searchParamsCache = createSearchParamsCache({
q: parseAsString.withDefault(''),
page: parseAsInteger.withDefault(1)
})
// app/search/page.tsx (Server Component)
import { searchParamsCache } from '@/lib/searchParams'
import type { SearchParams } from 'nuqs/server'
type Props = { searchParams: Promise<SearchParams> }
export default async function Page({ searchParams }: Props) {
// ⚠️ Must call parse() - don't forget!
const { q, page } = await searchParamsCache.parse(searchParams)
return <Results query={q} page={page} />
}
// Nested server component - no props needed
function NestedComponent() {
const page = searchParamsCache.get('page') // type-safe!
return <span>Page {page}</span>
}
Reusable Patterns
Shared Parser Definitions
// lib/parsers.ts
export const paginationParsers = {
page: parseAsInteger.withDefault(1),
limit: parseAsInteger.withDefault(20),
sort: parseAsString.withDefault('createdAt'),
order: parseAsStringLiteral(['asc', 'desc'] as const).withDefault('desc')
}
// Component
const [pagination, setPagination] = useQueryStates(paginationParsers)
URL Key Mapping
const [coords, setCoords] = useQueryStates(
{
latitude: parseAsFloat.withDefault(0),
longitude: parseAsFloat.withDefault(0)
},
{
urlKeys: { latitude: 'lat', longitude: 'lng' }
}
)
// URL: ?lat=45.5&lng=-122.6
// Code: coords.latitude, coords.longitude
Custom Hook
// hooks/useFilters.ts
export function useFilters() {
return useQueryStates({
search: parseAsString.withDefault(''),
category: parseAsString,
minPrice: parseAsFloat,
maxPrice: parseAsFloat,
inStock: parseAsBoolean.withDefault(false)
})
}
// Component
const [filters, setFilters] = useFilters()
Testing
import { withNuqsTestingAdapter, type UrlUpdateEvent } from 'nuqs/adapters/testing'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
it('updates URL on click', async () => {
const user = userEvent.setup()
const onUrlUpdate = vi.fn<[UrlUpdateEvent]>()
render(<CounterButton />, {
wrapper: withNuqsTestingAdapter({
searchParams: '?count=1',
onUrlUpdate
})
})
await user.click(screen.getByRole('button'))
expect(screen.getByRole('button')).toHaveTextContent('count is 2')
expect(onUrlUpdate).toHaveBeenCalledOnce()
const event = onUrlUpdate.mock.calls[0]![0]!
expect(event.queryString).toBe('?count=2')
expect(event.searchParams.get('count')).toBe('2')
expect(event.options.history).toBe('push')
})
Critical Mistakes to Avoid
1. Missing Adapter
// ❌ Error: nuqs requires an adapter
useQueryState('q')
// ✅ Wrap app in NuqsAdapter first (see Setup section)
2. Wrong Adapter for Framework
// ❌ Using app router adapter in pages router
import { NuqsAdapter } from 'nuqs/adapters/next/app' // Wrong!
// ✅ Match adapter to your router
import { NuqsAdapter } from 'nuqs/adapters/next/pages'
3. Missing Suspense (Next.js App Router)
// ❌ Hydration error
export default function Page() {
const [q] = useQueryState('q')
return <div>{q}</div>
}
// ✅ Wrap client components in Suspense
export default function Page() {
return (
<Suspense fallback={<Loading />}>
<SearchClient />
</Suspense>
)
}
4. Same Key, Different Parsers
// ❌ Conflicts - last update wins with wrong type
const [intVal] = useQueryState('foo', parseAsInteger)
const [floatVal] = useQueryState('foo', parseAsFloat)
// ✅ One parser per key, share via custom hook
function useFoo() {
const [val, setVal] = useQueryState('foo', parseAsFloat)
return { float: val, int: Math.floor(val ?? 0), setVal }
}
5. Forgetting to Parse on Server
// ❌ Returns cache object, not values
const values = searchParamsCache // Wrong!
// ✅ Call parse() with searchParams prop
const values = await searchParamsCache.parse(searchParams)
6. Server Component with Client Hook
// ❌ useQueryState only works in client components
export default function Page() { // Server component
const [q] = useQueryState('q') // Error!
}
// ✅ Use createSearchParamsCache for server, useQueryState for client
7. Not Handling Null Without Default
// ❌ Tedious null handling
const [count, setCount] = useQueryState('count', parseAsInteger)
setCount(c => (c ?? 0) + 1) // Must handle null every time
// ✅ Use withDefault
const [count, setCount] = useQueryState('count', parseAsInteger.withDefault(0))
setCount(c => c + 1) // Always a number
8. Lossy Serialization
// ❌ Loses precision on reload
const geoParser = {
parse: parseFloat,
serialize: v => v.toFixed(2) // 1.23456 → "1.23" → 1.23
}
// ✅ Preserve precision or accept the tradeoff knowingly
const geoParser = {
parse: parseFloat,
serialize: v => v.toString()
}
9. Missing eq for Reference Types
// ❌ clearOnDefault won't work correctly
const dateParser = {
parse: (v) => new Date(v),
serialize: (d) => d.toISOString()
}
// ✅ Provide eq function for reference types
const dateParser = createParser({
parse: (v) => new Date(v),
serialize: (d) => d.toISOString(),
eq: (a, b) => a.getTime() === b.getTime()
})
Quick Reference
| Task | Solution |
|---|---|
| Single param | useQueryState('key', parser.withDefault(val)) |
| Multiple params | useQueryStates({ key: parser }) |
| Server access | createSearchParamsCache + .parse() |
| Notify server | { shallow: false } |
| History entry | { history: 'push' } |
| Loading state | useTransition + { startTransition } |
| Short URL keys | urlKeys: { longName: 'short' } |
| Array param | parseAsArrayOf(parser) or parseAsArrayOf(parser, ';') |
| Enum/literal | parseAsStringLiteral(['a', 'b'] as const) |
| Custom type | createParser({ parse, serialize, eq }) |
| Test component | withNuqsTestingAdapter({ searchParams: '?...' }) |
Source
git clone https://github.com/noklip-io/agent-skills/blob/main/skills/nuqs/SKILL.mdView on GitHub Overview
nuqs provides a type-safe way to store UI state in the URL. It works like useState, but stores values as URL query parameters, enabling shareable links, bookmarking, and easy syncing of filters, sorts, and pagination. It supports adapters for Next.js, Remix, React Router, or plain React.
How This Skill Works
Wrap your app with NuqsAdapter for your framework (Next.js, Remix, React Router, or React). Use useQueryState or useQueryStates to read and update query parameters; updates mutate the URL automatically. Parsers (e.g., parseAsString, parseAsInteger, parseAsBoolean) provide typed values and can include defaults via withDefault.
When to Use It
- Implement URL query state in a React app to reflect UI state in the URL
- Manage search params and filter/sort state in the URL
- Synchronize component state with the URL for shareable, bookmarkable pages
- Build filterable/sortable lists with URL-backed state
- Handle pagination with URL state across Next.js, Remix, React Router, or plain React
Quick Start
- Step 1: Install nuqs and wrap your app with the appropriate NuqsAdapter (Next.js, Remix, React Router, or React).
- Step 2: Read and write params with useQueryState or useQueryStates, e.g. const [search, setSearch] = useQueryState('q')
- Step 3: Apply parsers and defaults, then update values (e.g., setSearch('hello') or setPage(p => p + 1))
Best Practices
- Wrap your app early with NuqsAdapter for the target framework
- Use the built-in parsers withDefault to enforce typed values and defaults
- Leverage useQueryStates for multiple params and batch updates
- Use functional updates and await set operations to read resulting URLSearchParams
- Configure defaultOptions (shallow, scroll, clearOnDefault, limitUrlUpdates) to optimize UX
Example Use Cases
- Search and filter: use useQueryState('q', parseAsString) with default '' and update '?q=hello'
- Pagination: use useQueryState('page', parseAsInteger.withDefault(1)) and increment pages
- Sorting: use useQueryState('sort', parseAsString.withDefault('date')) to reflect sort order
- Batch updates: use useQueryStates({ page, sort, q }) and await setFilters(...) for a single URL update
- Boolean flag: use parseAsBoolean to reflect flags like '?active=true' in the URL