
Nuqs
- 1.2k installs
- 186 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
nuqs provides documented workflows for nuqs (type-safe URL query state) best practices for Next.js and other React frameworks. This skill should be used when writing, reviewing, or refactoring code t
About
The nuqs skill nuqs type-safe URL query state best practices for Next js and other React frameworks This skill should be used when writing reviewing or refactoring code that uses nuqs for URL state management Triggers on tasks involving useQueryState useQueryStates search params URL state query parameters nuqs parsers limitUrlUpdates Standard Schema NuqsAdapter or Next js routing with state Community nuqs Best Practices for Next js React Comprehensive guide for type-safe URL query state management with nuqs across Next js React Router TanStack Router Remix and plain React Covers nuqs v2 5 v2 8 features Contains 46 rules across 8 categories prioritized by impact to guide code generation refactoring and code review When to Apply Reference these guidelines when Implementing URL-based state with nuqs Setting up nuqs in a Next js or React Router project Configuring parsers for URL parameters Integrating URL state with Server Components Optimizing URL update performance limitUrlUpdates key isolation Sharing parser definitions with tRPC TanStack Router forms via Standard Schema Debugging nuqs-related issues Rule Categories by
- Implementing URL-based state with nuqs
- Setting up nuqs in a Next.js or React Router project
- Configuring parsers for URL parameters
- Integrating URL state with Server Components
- Optimizing URL update performance (`limitUrlUpdates`, key isolation)
Nuqs by the numbers
- 1,249 all-time installs (skills.sh)
- +45 installs in the week ending Jul 29, 2026 (Skillselion tracking)
- Ranked #347 of 2,203 Security skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Jul 31, 2026 (Skillselion catalog sync)
nuqs capabilities & compatibility
- Capabilities
- implementing url based state with nuqs · setting up nuqs in a next.js or react router pro · configuring parsers for url parameters · integrating url state with server components · optimizing url update performance (`limiturlupda
- Use cases
- documentation
What nuqs says it does
# Community nuqs Best Practices for Next.js & React Comprehensive guide for type-safe URL query state management with nuqs across Next.js, React Router, TanStack Router, Remix, and plain React.
Contains 46 rules across 8 categories, prioritized by impact to guide code generation, refactoring, and code review.
npx skills add https://github.com/pproenca/dot-skills --skill nuqsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.2k |
|---|---|
| repo stars | ★ 186 |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I use nuqs for the task described in its SKILL.md triggers?
nuqs (type-safe URL query state) best practices for Next.js and other React frameworks. This skill should be used when writing, reviewing, or refactoring code that uses nuqs for URL state management.
Who is it for?
Teams invoking nuqs when the user request matches documented triggers and prerequisites.
Skip if: Skip when cached docs are missing, the request is a negative trigger, or another sibling skill owns the workflow.
When should I use this skill?
nuqs (type-safe URL query state) best practices for Next.js and other React frameworks. This skill should be used when writing, reviewing, or refactoring code that uses nuqs for URL state management. Triggers on tasks in
What you get
Step-by-step guidance grounded in nuqs documentation and reference files.
- nuqs-compliant URL state code
- validated parser configurations
By the numbers
- Contains 46 rules across 8 categories
- Covers nuqs v2.5 through v2.8 features
Files
Community nuqs Best Practices for Next.js & React
Comprehensive guide for type-safe URL query state management with nuqs across Next.js, React Router, TanStack Router, Remix, and plain React. Covers nuqs v2.5–v2.8 features. Contains 46 rules across 8 categories, prioritized by impact to guide code generation, refactoring, and code review.
When to Apply
Reference these guidelines when:
- Implementing URL-based state with nuqs
- Setting up nuqs in a Next.js or React Router project
- Configuring parsers for URL parameters
- Integrating URL state with Server Components
- Optimizing URL update performance (
limitUrlUpdates, key isolation) - Sharing parser definitions with tRPC / TanStack Router / forms via Standard Schema
- Debugging nuqs-related issues
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Parser Configuration | CRITICAL | parser- |
| 2 | Adapter & Setup | CRITICAL | setup- |
| 3 | State Management | HIGH | state- |
| 4 | Server Integration | HIGH | server- |
| 5 | Performance Optimization | MEDIUM | perf- |
| 6 | History & Navigation | MEDIUM | history- |
| 7 | Debugging & Testing | LOW-MEDIUM | debug- |
| 8 | Advanced Patterns | LOW | advanced- |
Quick Reference
1. Parser Configuration (CRITICAL)
- `parser-use-typed-parsers` — Use typed parsers for non-string values
- `parser-with-default` — Use withDefault for non-nullable state
- `parser-enum-validation` — Use enum parsers for constrained values
- `parser-array-format` — Choose correct array parser format
- `parser-json-validation` — Validate JSON parser input
- `parser-date-format` — Select appropriate date parser
- `parser-index-offset` — Use parseAsIndex for 1-based URL display
- `parser-hex-colors` — Use parseAsHex for color values
2. Adapter & Setup (CRITICAL)
- `setup-nuqs-adapter` — Wrap app with NuqsAdapter
- `setup-use-client` — Add 'use client' directive for hooks
- `setup-import-server` — Import server utilities from nuqs/server
- `setup-nextjs-version` — Ensure compatible Next.js version
- `setup-shared-parsers` — Define shared parsers in dedicated file
- `setup-default-options` — Configure app-wide defaults on NuqsAdapter (v2.5+)
3. State Management (HIGH)
- `state-use-query-states` — Use useQueryStates for related parameters
- `state-functional-updates` — Use functional updates for derived state
- `state-clear-with-null` — Clear URL parameters with null
- `state-controlled-inputs` — Handle controlled input value properly
- `state-avoid-derived` — Avoid derived state from URL parameters
- `state-options-inheritance` — Use withOptions for parser-level configuration
- `state-setter-return` — Use setter return value for URL access
- `state-standard-schema` — Use Standard Schema for cross-library validation (v2.5+)
4. Server Integration (HIGH)
- `server-search-params-cache` — Use createSearchParamsCache (or createLoader) for Server Components
- `server-shallow-false` — Use shallow:false to trigger server re-renders
- `server-use-transition` — Integrate useTransition for loading states
- `server-parse-before-get` — Call parse() before get() in Server Components
- `server-share-parsers` — Share parsers between client and server
- `server-next15-async` — Handle async searchParams in Next.js 15+
5. Performance Optimization (MEDIUM)
- `perf-throttle-updates` — Throttle rapid URL updates with
limitUrlUpdates - `perf-debounce-search` — Debounce search input with built-in
limitUrlUpdates - `perf-clear-on-default` — Use clearOnDefault for clean URLs
- `perf-avoid-rerender` — Memoize components using URL state (Next.js)
- `perf-key-isolation` — Rely on key isolation outside Next.js (v2.5+)
- `perf-serialize-utility` — Use createSerializer for link URLs
6. History & Navigation (MEDIUM)
- `history-push-navigation` — Use history:push for navigation-like state
- `history-replace-ephemeral` — Use history:replace for ephemeral state
- `history-scroll-behavior` — Control scroll behavior on URL changes
- `history-back-sync` — Handle browser back/forward navigation
7. Debugging & Testing (LOW-MEDIUM)
- `debug-enable-logging` — Enable debug logging for troubleshooting
- `debug-common-errors` — Diagnose common nuqs errors
- `debug-testing` — Test components with URL state
8. Advanced Patterns (LOW)
- `advanced-custom-parsers` — Create custom parsers for complex types
- `advanced-url-keys` — Use urlKeys for shorter URLs
- `advanced-eq-function` — Implement eq function for object parsers
- `advanced-framework-adapters` — Use framework-specific adapters
- `advanced-process-url-search-params` — Canonicalize URL shape with
processUrlSearchParams(v2.6+)
How to Use
Read individual reference files for detailed explanations and code examples:
- Section definitions — Category structure and impact levels
- Rule template — Template for adding new rules
Reference Files
| File | Description |
|---|---|
| AGENTS.md | Complete compiled guide with all rules |
| references/_sections.md | Category definitions and ordering |
| assets/templates/_template.md | Template for new rules |
| metadata.json | Version and reference information |
nuqs
Version 1.2.0 Community May 2026
Note:
This nuqs best-practices document is mainly for agents and LLMs to follow when maintaining,
generating, or refactoring codebases that use nuqs. Humans may also find it useful,
but guidance here is optimized for automation and consistency by AI-assisted workflows.
---
Abstract
Comprehensive best practices guide for nuqs (type-safe URL query state management) in Next.js and other React frameworks, designed for AI agents and LLMs. Covers nuqs v2.5–v2.8 features including limitUrlUpdates (built-in debounce/throttle), key isolation, Standard Schema integration, defaultOptions on NuqsAdapter, processUrlSearchParams middleware, and the createLoader server utility. Contains 46 rules across 8 categories, prioritized by impact from critical (parser configuration, adapter setup) to incremental (advanced patterns). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation.
---
Table of Contents
1. Parser Configuration — CRITICAL
- 1.1 Choose Correct Array Parser Format — CRITICAL (prevents API integration failures from wrong URL format)
- 1.2 Select Appropriate Date Parser — CRITICAL (prevents timezone bugs and parsing failures)
- 1.3 Use Enum Parsers for Constrained Values — CRITICAL (prevents invalid state from URL manipulation)
- 1.4 Use parseAsHex for Color Values — MEDIUM (50% shorter URLs for color parameters)
- 1.5 Use parseAsIndex for 1-Based URL Display — HIGH (eliminates off-by-one errors between URL and code)
- 1.6 Use Typed Parsers for Non-String Values — CRITICAL (prevents runtime type errors and hydration mismatches)
- 1.7 Use withDefault for Non-Nullable State — CRITICAL (eliminates null checks throughout component tree)
- 1.8 Validate JSON Parser Input — CRITICAL (prevents runtime crashes and unsafe casts from URL-supplied JSON)
2. Adapter & Setup — CRITICAL
- 2.1 Add 'use client' Directive for Hooks — CRITICAL (prevents build-breaking hook errors in RSC)
- 2.2 Configure App-Wide Defaults on NuqsAdapter — MEDIUM (avoids repeating .withOptions on every parser; enforces consistent behaviour)
- 2.3 Define Shared Parsers in Dedicated File — HIGH (prevents parser mismatch bugs between components)
- 2.4 Ensure Compatible Next.js Version — CRITICAL (prevents cryptic runtime errors from version mismatch)
- 2.5 Import Server Utilities from nuqs/server — CRITICAL (prevents RSC-to-client boundary contamination errors)
- 2.6 Wrap App with NuqsAdapter — CRITICAL (prevents 100% of hook failures from missing provider)
3. State Management — HIGH
- 3.1 Avoid Derived State from URL Parameters — HIGH (prevents sync bugs and unnecessary re-renders)
- 3.2 Clear URL Parameters with null — HIGH (reduces URL clutter by removing unnecessary parameters)
- 3.3 Handle Controlled Input Value Properly — HIGH (prevents uncontrolled to controlled warnings)
- 3.4 Use Functional Updates for State Derived from Previous Value — HIGH (prevents stale closure bugs and race conditions)
- 3.5 Use Setter Return Value for URL Access — MEDIUM (enables accurate URL tracking for analytics/sharing without re-deriving the URL)
- 3.6 Use Standard Schema for Cross-Library Validation — MEDIUM (eliminates 2-4× duplicate schema definitions across nuqs, tRPC, route validators, and forms)
- 3.7 Use useQueryStates for Related Parameters — HIGH (gives a single typed object and one combined URLSearchParams flush)
- 3.8 Use withOptions for Parser-Level Configuration — MEDIUM (reduces boilerplate and ensures consistent behavior)
4. Server Integration — HIGH
- 4.1 Call parse() Before get() in Server Components — HIGH (prevents undefined values and runtime errors)
- 4.2 Handle Async searchParams in Next.js 15+ — HIGH (prevents build errors in Next.js 15 with async props)
- 4.3 Integrate useTransition for Loading States — HIGH (100% visibility into server fetch pending state)
- 4.4 Share Parsers Between Client and Server — HIGH (prevents client/server hydration mismatches)
- 4.5 Use createSearchParamsCache (or createLoader) for Server Components — HIGH (eliminates prop drilling across N component levels)
- 4.6 Use shallow:false to Trigger Server Re-renders — HIGH (enables server-side data refetching on URL change)
5. Performance Optimization — MEDIUM
- 5.1 Debounce Search Input Before URL Update — HIGH (reduces server requests during typing from N per keystroke to 1 per pause)
- 5.2 Memoize Components Using URL State — MEDIUM (prevents unnecessary re-renders on URL changes — Next.js especially)
- 5.3 Rely on Key Isolation Outside Next.js — HIGH (avoids unnecessary memoization on adapters that already scope re-renders per key)
- 5.4 Throttle Rapid URL Updates — MEDIUM (prevents browser history API rate limiting on rapid input)
- 5.5 Use clearOnDefault for Clean URLs — MEDIUM (reduces URL length by 20-50% for default values)
- 5.6 Use createSerializer for Link URLs — MEDIUM (enables SSR-compatible URL generation without hooks)
6. History & Navigation — MEDIUM
- 6.1 Control Scroll Behavior on URL Changes — MEDIUM (prevents jarring scroll jumps on state changes)
- 6.2 Handle Browser Back/Forward Navigation — MEDIUM (prevents stale UI after browser navigation)
- 6.3 Use history:push for Navigation-Like State — MEDIUM (enables back button for state navigation)
- 6.4 Use history:replace for Ephemeral State — MEDIUM (prevents history pollution from frequent updates)
7. Debugging & Testing — LOW-MEDIUM
- 7.1 Diagnose Common nuqs Errors — LOW-MEDIUM (reduces debugging time from hours to minutes)
- 7.2 Enable Debug Logging for Troubleshooting — LOW-MEDIUM (reduces debugging time by 5-10×)
- 7.3 Test Components with URL State — LOW-MEDIUM (enables reliable CI/CD testing of nuqs components)
8. Advanced Patterns — LOW
- 8.1 Create Custom Parsers for Complex Types — LOW (prevents runtime errors from string coercion)
- 8.2 Implement eq Function for Object Parsers — LOW (prevents unnecessary URL updates for equivalent objects)
- 8.3 Use Framework-Specific Adapters — LOW (prevents URL sync failures in non-Next.js apps)
- 8.4 Use processUrlSearchParams for Canonical URL Shape — LOW-MEDIUM (enables stable URL ordering for SEO and cache hit-rate)
- 8.5 Use urlKeys for Shorter URLs — LOW (reduces URL length by 50-70% for verbose params)
---
References
1. https://nuqs.dev 2. https://nuqs.dev/blog/nuqs-2.5 3. https://github.com/47ng/nuqs 4. https://nextjs.org/docs 5. https://react.dev 6. https://standardschema.dev
---
Source Files
This document was compiled from individual reference files. For detailed editing or extension:
| File | Description |
|---|---|
| references/_sections.md | Category definitions and impact ordering |
| assets/templates/_template.md | Template for creating new rules |
| SKILL.md | Quick reference entry point |
| metadata.json | Version and reference URLs |
{Rule Title}
{1-3 sentences explaining WHY this matters. Focus on performance/correctness implications.}
Incorrect ({what's wrong}):
{Bad code example - production-realistic, not strawman}
{// Comments explaining the cost}Correct ({what's right}):
{Good code example - minimal diff from incorrect}
{// Comments explaining the benefit}{Optional sections as needed:}
Alternative ({context}):
{Alternative approach when applicable}When NOT to use this pattern:
- {Exception 1}
- {Exception 2}
Benefits:
- {Benefit 1}
- {Benefit 2}
Reference: [{Reference Title}]({Reference URL})
{
"version": "1.1.6",
"organization": "Community",
"technology": "nuqs",
"date": "May 2026",
"abstract": "Comprehensive best practices guide for nuqs (type-safe URL query state management) in Next.js and other React frameworks, designed for AI agents and LLMs. Covers nuqs v2.5–v2.8 features including limitUrlUpdates (built-in debounce/throttle), key isolation, Standard Schema integration, defaultOptions on NuqsAdapter, processUrlSearchParams middleware, and the createLoader server utility. Contains 46 rules across 8 categories, prioritized by impact from critical (parser configuration, adapter setup) to incremental (advanced patterns). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation.",
"references": [
"https://nuqs.dev",
"https://nuqs.dev/blog/nuqs-2.5",
"https://github.com/47ng/nuqs",
"https://nextjs.org/docs",
"https://react.dev",
"https://standardschema.dev"
],
"category": "Frontend"
}
nuqs Best Practices for Next.js
A comprehensive best practices skill for using nuqs - type-safe URL query state management - in Next.js applications.
Overview
This skill provides 42 rules across 8 categories to help AI agents and developers write correct, performant, and maintainable code when using nuqs for URL state management.
Getting Started
# Install dependencies (if any)
pnpm install
# Build AGENTS.md from references
pnpm build
# Validate the skill
pnpm validateCategories
| Priority | Category | Impact | Rules |
|---|---|---|---|
| 1 | Parser Configuration | CRITICAL | 8 |
| 2 | Adapter & Setup | CRITICAL | 5 |
| 3 | State Management | HIGH | 7 |
| 4 | Server Integration | HIGH | 6 |
| 5 | Performance Optimization | MEDIUM | 5 |
| 6 | History & Navigation | MEDIUM | 4 |
| 7 | Debugging & Testing | LOW-MEDIUM | 3 |
| 8 | Advanced Patterns | LOW | 4 |
Creating a New Rule
1. Create a new file in references/ with the pattern {prefix}-{slug}.md 2. Use the template from assets/templates/_template.md 3. Include YAML frontmatter with title, impact, impactDescription, tags 4. Add Incorrect and Correct code examples 5. Run pnpm build to regenerate AGENTS.md 6. Run pnpm validate to check for errors
Rule File Structure
---
title: Rule Title
impact: CRITICAL|HIGH|MEDIUM|LOW
impactDescription: Quantified impact (e.g., "2-10× improvement")
tags: prefix, keyword1, keyword2
---
## Rule Title
Explanation of WHY this matters.
**Incorrect (annotation):**
\`\`\`tsx
// Bad code example
\`\`\`
**Correct (annotation):**
\`\`\`tsx
// Good code example
\`\`\`
Reference: [Link](url)File Naming Convention
- Rule files:
{prefix}-{descriptive-slug}.md(e.g.,parser-use-typed-parsers.md) - Prefix must match a section defined in
references/_sections.md - Use lowercase with hyphens
Impact Levels
| Level | Description |
|---|---|
| CRITICAL | Causes build failures, runtime errors, or major performance issues |
| HIGH | Significant bugs, performance degradation, or maintenance problems |
| MEDIUM | Noticeable issues or suboptimal patterns |
| LOW-MEDIUM | Minor improvements or edge case handling |
| LOW | Nice-to-have optimizations or advanced patterns |
Scripts
pnpm build- Regenerate AGENTS.md from referencespnpm validate- Check skill against quality guidelines
Contributing
1. Check existing rules to avoid duplication 2. Follow the rule template structure 3. Include production-realistic code examples 4. Quantify impact where possible 5. Run validation before submitting
References
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Parser Configuration (parser)
Impact: CRITICAL Description: Incorrect parsers cause type mismatches, runtime errors, and hydration failures. Parser selection cascades through the entire state lifecycle.
2. Adapter & Setup (setup)
Impact: CRITICAL Description: Missing NuqsAdapter or incorrect setup causes hooks to fail silently or throw. Foundation for all nuqs functionality.
3. State Management (state)
Impact: HIGH Description: Proper use of useQueryState vs useQueryStates, default values, and null handling prevents unnecessary complexity and bugs.
4. Server Integration (server)
Impact: HIGH Description: Server cache and shallow routing configuration determines whether state changes trigger expensive server re-renders.
5. Performance Optimization (perf)
Impact: MEDIUM Description: Throttling, batching, and update coalescing prevent browser rate-limiting and reduce unnecessary URL updates.
6. History & Navigation (history)
Impact: MEDIUM Description: History mode selection affects UX - push vs replace impacts back button behavior and navigation experience.
7. Debugging & Testing (debug)
Impact: LOW-MEDIUM Description: Debug logging, testing strategies, and common error diagnosis enable faster development cycles.
8. Advanced Patterns (advanced)
Impact: LOW Description: Custom parsers, serializers, URL key mapping for complex use cases requiring careful implementation.
Create Custom Parsers for Complex Types
When built-in parsers don't fit your needs, create custom parsers with createParser. Define parse, serialize, and optionally eq for equality checking.
Incorrect (manual parsing in component):
'use client'
import { useQueryState } from 'nuqs'
interface SortState {
id: string
desc: boolean
}
export default function SortableTable() {
const [sortRaw, setSortRaw] = useQueryState('sort')
// Manual parsing scattered across component
const sort: SortState = sortRaw
? { id: sortRaw.split(':')[0], desc: sortRaw.split(':')[1] === 'desc' }
: { id: 'name', desc: false }
const handleSort = (id: string) => {
// Manual serialization
setSortRaw(`${id}:${sort.id === id && !sort.desc ? 'desc' : 'asc'}`)
}
}Correct (custom parser):
'use client'
import { useQueryState, createParser } from 'nuqs'
interface SortState {
id: string
desc: boolean
}
const parseAsSort = createParser<SortState>({
parse(query) {
const [id = '', direction = ''] = query.split(':')
return { id, desc: direction === 'desc' }
},
serialize(value) {
return `${value.id}:${value.desc ? 'desc' : 'asc'}`
},
eq(a, b) {
return a.id === b.id && a.desc === b.desc
}
})
export default function SortableTable() {
const [sort, setSort] = useQueryState(
'sort',
parseAsSort.withDefault({ id: 'name', desc: false })
)
// Type-safe, reusable, with proper equality checking
}Typing parser-returning helpers (v2.7+):
If you write a function that returns a custom parser (e.g. for a factory), type the return as SingleParserBuilder<T>. The older ParserBuilder<T> symbol is deprecated and will be removed in v3.
import { createParser, type SingleParserBuilder } from 'nuqs'
export function parseAsTuple<A, B>(
a: SingleParserBuilder<A>,
b: SingleParserBuilder<B>
): SingleParserBuilder<[A, B]> {
return createParser<[A, B]>({
parse(query) {
const [left, right] = query.split('|')
const A = a.parse(left ?? '')
const B = b.parse(right ?? '')
return A === null || B === null ? null : [A, B]
},
serialize([A, B]) {
return `${a.serialize(A)}|${b.serialize(B)}`
}
})
}For object/JSON-shaped values: prefer parseAsJson with a Standard Schema (Zod, Valibot, ArkType, Effect Schema) over a hand-rolled createParser — see parser-json-validation and state-standard-schema.
Reference: nuqs Custom Parsers
Implement eq Function for Object Parsers
When creating custom parsers for objects, implement the eq function to define equality. Without it, nuqs uses reference equality, causing unnecessary URL updates for equivalent but different object instances.
Incorrect (reference equality):
import { createParser } from 'nuqs'
interface Filters {
minPrice: number
maxPrice: number
}
const parseAsFilters = createParser<Filters>({
parse(query) {
const [min, max] = query.split('-').map(Number)
return { minPrice: min, maxPrice: max }
},
serialize({ minPrice, maxPrice }) {
return `${minPrice}-${maxPrice}`
}
// Missing eq - uses reference equality
})
// Problem: setting same values creates new object references
setFilters({ minPrice: 0, maxPrice: 100 })
setFilters({ minPrice: 0, maxPrice: 100 }) // Triggers URL update even though values are sameCorrect (value equality):
import { createParser } from 'nuqs'
interface Filters {
minPrice: number
maxPrice: number
}
const parseAsFilters = createParser<Filters>({
parse(query) {
const [min, max] = query.split('-').map(Number)
return { minPrice: min, maxPrice: max }
},
serialize({ minPrice, maxPrice }) {
return `${minPrice}-${maxPrice}`
},
eq(a, b) {
return a.minPrice === b.minPrice && a.maxPrice === b.maxPrice
}
})
// Now same values don't trigger unnecessary updates
setFilters({ minPrice: 0, maxPrice: 100 })
setFilters({ minPrice: 0, maxPrice: 100 }) // No URL update - values are equalFor arrays:
const parseAsIdList = createParser<number[]>({
parse(query) {
return query.split(',').map(Number)
},
serialize(value) {
return value.join(',')
},
eq(a, b) {
return a.length === b.length && a.every((v, i) => v === b[i])
}
})For nested objects:
import isEqual from 'lodash/isEqual' // or deep-equal
const parseAsConfig = createParser<Config>({
parse: JSON.parse,
serialize: JSON.stringify,
eq: isEqual // Deep equality comparison
})Reference: nuqs Custom Parsers
Use Framework-Specific Adapters
nuqs works with multiple React frameworks through adapters. Use the correct adapter for your framework to ensure proper URL synchronisation. Picking the wrong adapter usually fails silently — hooks return stale values or never update the URL.
Incorrect (Next.js adapter inside a React Router tree):
// src/main.tsx
import { NuqsAdapter } from 'nuqs/adapters/next/app' // Wrong runtime
import { BrowserRouter } from 'react-router-dom'
function App() {
return (
<BrowserRouter>
<NuqsAdapter>
<Routes />
</NuqsAdapter>
</BrowserRouter>
)
}Correct (React Router v6 adapter):
// src/main.tsx
import { NuqsAdapter } from 'nuqs/adapters/react-router/v6'
import { BrowserRouter } from 'react-router-dom'
function App() {
return (
<BrowserRouter>
<NuqsAdapter>
<Routes />
</NuqsAdapter>
</BrowserRouter>
)
}Available adapters (current as of nuqs v2.8):
| Framework | Import Path | Notes |
|---|---|---|
| Next.js App Router | nuqs/adapters/next/app | |
| Next.js Pages Router | nuqs/adapters/next/pages | |
| Next.js (unified) | nuqs/adapters/next | Use when an app mixes both routers |
| React Router v5 | nuqs/adapters/react-router/v5 | Added in v2.8 |
| React Router v6 | nuqs/adapters/react-router/v6 | |
| React Router v7 | nuqs/adapters/react-router/v7 | |
| Remix | nuqs/adapters/remix | |
| TanStack Router | nuqs/adapters/tanstack-router | Added in v2.5; TanStack Start support is still WIP |
| Plain React (no router) | nuqs/adapters/react | For Vite / CRA apps with no router |
| Testing | nuqs/adapters/testing | See debug-testing |
Deprecation: The unversioned nuqs/adapters/react-router import (which used to alias v6) is deprecated and slated for removal in nuqs v3. Always pin the version explicitly.
Key isolation (v2.5+): All non-Next.js adapters scope re-renders to the specific URL key a hook subscribes to. Next.js continues to re-render the entire subtree on any URL change because its URLSearchParams context is global. If fine-grained re-renders matter and you're not on Next.js, you generally don't need to memoize aggressively. See perf-avoid-rerender.
Reference: nuqs Adapters
Use processUrlSearchParams for Canonical URL Shape
nuqs v2.6 added processUrlSearchParams, a middleware that transforms the URLSearchParams object just before it is written to the URL (when passed as an NuqsAdapter prop) or just before the URL is serialised (when passed to createSerializer). The most common use is to canonicalise key ordering — without it, ?b=2&a=1 and ?a=1&b=2 are two different cache keys for the same logical query, hurting SEO and CDN hit-rate.
Incorrect (key order leaks from React render order):
// User toggles filters in different orders → different URLs for the same query
// /search?tag=react&sort=desc
// /search?sort=desc&tag=react
// Both render the same results, but each is a distinct canonical URL for Google.Correct (sort keys via adapter middleware):
// app/layout.tsx
import { NuqsAdapter } from 'nuqs/adapters/next/app'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<NuqsAdapter
processUrlSearchParams={(params) => {
// Sort keys alphabetically before each write
params.sort()
return params
}}
>
{children}
</NuqsAdapter>
</body>
</html>
)
}For SEO canonical URLs in `generateMetadata` — same hook on `createSerializer`:
// lib/searchParams.ts
import { createSerializer, parseAsString, parseAsInteger } from 'nuqs/server'
export const searchParamsMap = {
q: parseAsString.withDefault(''),
page: parseAsInteger.withDefault(1)
}
export const serializeSearch = createSerializer(searchParamsMap, {
processUrlSearchParams(params) {
params.sort()
return params
}
})
// app/search/page.tsx
import type { Metadata } from 'next'
import { serializeSearch } from '@/lib/searchParams'
export async function generateMetadata({ searchParams }): Promise<Metadata> {
const params = await searchParams
return {
alternates: {
canonical: serializeSearch('/search', params)
}
}
}Other uses:
- Stripping internal tracking params (
utm_*) before write. - Coercing booleans to a stable string form (
truevs.1). - Removing empty values that didn't get caught by
clearOnDefaultfor legacy reasons.
When NOT to use this pattern:
- Single-page app with no SEO concerns — the indirection costs more than the wobble.
- You need to drop keys that affect server-side parsing — strip them on the server (in
createSearchParamsCache/createLoader) instead, where the parser map is the source of truth.
Reference: nuqs Options
Use urlKeys for Shorter URLs
Map verbose parameter names to shorter URL keys for cleaner, more shareable URLs while keeping descriptive names in code.
Incorrect (verbose URL parameters):
'use client'
import { useQueryStates, parseAsFloat, parseAsInteger } from 'nuqs'
export default function MapView() {
const [coords, setCoords] = useQueryStates({
latitude: parseAsFloat.withDefault(0),
longitude: parseAsFloat.withDefault(0),
zoomLevel: parseAsInteger.withDefault(10)
})
// URL: ?latitude=48.8566&longitude=2.3522&zoomLevel=12
// Long, harder to share, uses more bandwidth
return <Map {...coords} />
}Correct (abbreviated URL keys):
'use client'
import { useQueryStates, parseAsFloat, parseAsInteger } from 'nuqs'
export default function MapView() {
const [coords, setCoords] = useQueryStates(
{
latitude: parseAsFloat.withDefault(0),
longitude: parseAsFloat.withDefault(0),
zoomLevel: parseAsInteger.withDefault(10)
},
{
urlKeys: {
latitude: 'lat',
longitude: 'lng',
zoomLevel: 'z'
}
}
)
// URL: ?lat=48.8566&lng=2.3522&z=12
// Shorter, cleaner URLs
// Code still uses descriptive names
console.log(coords.latitude, coords.longitude, coords.zoomLevel)
return <Map {...coords} />
}Reference: nuqs urlKeys
Diagnose Common nuqs Errors
Reference for diagnosing frequent nuqs issues and their solutions.
Incorrect (missing adapter causes cryptic error):
// app/layout.tsx - Missing NuqsAdapter
export default function RootLayout({ children }) {
return (
<html>
<body>{children}</body>
</html>
)
}
// Error: "Cannot read property 'push' of undefined"Correct (add NuqsAdapter):
// app/layout.tsx
import { NuqsAdapter } from 'nuqs/adapters/next/app'
export default function RootLayout({ children }) {
return (
<html>
<body>
<NuqsAdapter>{children}</NuqsAdapter>
</body>
</html>
)
}Other common errors and fixes:
| Error | Cause | Fix |
|---|---|---|
| "Hooks can only be called inside Client Components" | Missing 'use client' | Add 'use client' directive |
| "Uncontrolled input" warning | Value is null | Use value={query ?? ''} |
| Hydration mismatch | Different defaults | Use shared parsers with withDefault |
| URL not updating | Missing adapter or old Next.js | Check adapter and version |
| State undefined in Server Component | Missing parse() | Call parse() before get() |
Reference: nuqs Documentation
Enable Debug Logging for Troubleshooting
Enable nuqs debug logs to understand state changes, URL updates, and timing. Useful for diagnosing issues with state synchronization or unexpected behavior.
Incorrect (no visibility into nuqs operations):
'use client'
import { useQueryState, parseAsInteger } from 'nuqs'
export default function Counter() {
const [count, setCount] = useQueryState('count', parseAsInteger.withDefault(0))
// Something's not working, but no way to see what nuqs is doing
// Have to guess and add console.logs everywhere
return <button onClick={() => setCount(c => c + 1)}>{count}</button>
}Correct (enable debug logging):
// Run in browser DevTools console FIRST
localStorage.debug = 'nuqs'
// Then reload the page
// Now you see: [nuqs] useQueryState 'count' initialized with 0
// And: [nuqs] useQueryState 'count' updated to 1Disable when done:
// Run in browser DevTools console
delete localStorage.debugPerformance timing markers:
Debug mode also records User Timing markers visible in the Performance tab:
nuqs:parse- Time to parse URL parametersnuqs:serialize- Time to serialize state to URLnuqs:update- Time for URL update
Reference: nuqs Debugging
Test Components with URL State
Test components that use nuqs by providing the NuqsTestingAdapter and controlling URL state in tests.
Incorrect (test fails without adapter):
// components/Pagination.test.tsx
import { render, screen } from '@testing-library/react'
import Pagination from './Pagination'
it('displays current page', () => {
render(<Pagination />) // Fails: no NuqsAdapter
expect(screen.getByText('Page 1')).toBeInTheDocument()
})Correct (use NuqsTestingAdapter):
// test/utils.tsx
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { render, type RenderOptions } from '@testing-library/react'
export function renderWithNuqs(
ui: React.ReactElement,
{ searchParams = {}, ...options }: RenderOptions & { searchParams?: Record<string, string> } = {}
) {
return render(
<NuqsTestingAdapter searchParams={searchParams}>
{ui}
</NuqsTestingAdapter>,
options
)
}
// components/Pagination.test.tsx
import { screen } from '@testing-library/react'
import { renderWithNuqs } from '@/test/utils'
import Pagination from './Pagination'
it('displays current page from URL', () => {
renderWithNuqs(<Pagination />, { searchParams: { page: '5' } })
expect(screen.getByText('Page 5')).toBeInTheDocument()
})Reference: nuqs Testing Adapter
Handle Browser Back/Forward Navigation
nuqs automatically syncs state with URL when users navigate with browser back/forward buttons. Ensure your UI handles these state changes correctly.
Incorrect (local state gets out of sync):
'use client'
import { useState, useEffect } from 'react'
import { useQueryState, parseAsInteger } from 'nuqs'
export default function Pagination() {
const [urlPage] = useQueryState('page', parseAsInteger.withDefault(1))
const [localPage, setLocalPage] = useState(urlPage)
// User navigates back - urlPage updates but localPage doesn't!
return <p>Page {localPage}</p> // Shows stale value
}Correct (use URL state directly):
'use client'
import { useQueryState, parseAsInteger } from 'nuqs'
export default function Pagination() {
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1).withOptions({
history: 'push'
}))
// User: page 1 → 2 → 3
// Back button: page becomes 2 (automatic)
// UI re-renders with new page value
return (
<div>
<p>Page {page}</p>
<button onClick={() => setPage(p => p + 1)}>Next</button>
</div>
)
}Reference: nuqs Documentation
Use history:push for Navigation-Like State
Use history: 'push' when state changes represent navigation that users would expect to undo with the back button (pagination, tabs, modal state).
Incorrect (replace loses navigation history):
'use client'
import { useQueryState, parseAsInteger } from 'nuqs'
export default function Pagination() {
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1))
// history: 'replace' (default)
// User clicks page 1 → 2 → 3 → back button → leaves site!
return (
<nav>
<button onClick={() => setPage(p => p - 1)}>Previous</button>
<span>Page {page}</span>
<button onClick={() => setPage(p => p + 1)}>Next</button>
</nav>
)
}Correct (push enables back navigation):
'use client'
import { useQueryState, parseAsInteger } from 'nuqs'
export default function Pagination() {
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1).withOptions({
history: 'push'
}))
// User clicks page 1 → 2 → 3 → back button → page 2
return (
<nav>
<button onClick={() => setPage(p => p - 1)}>Previous</button>
<span>Page {page}</span>
<button onClick={() => setPage(p => p + 1)}>Next</button>
</nav>
)
}Typical use cases for history:push:
- Pagination
- Tab selection
- Modal open/close state
- Step-by-step wizards
- Filter panel expansion
Mix modes when needed:
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1).withOptions({
history: 'push'
}))
// Programmatic navigation pushes
setPage(5)
// But "reset" replaces to avoid back-button spam
setPage(1, { history: 'replace' })Reference: nuqs History Option
Use history:replace for Ephemeral State
Use history: 'replace' (default) for state that changes frequently or represents intermediate values users wouldn't want to navigate through (typing, sliders, filters).
Correct (replace for typing):
'use client'
import { useQueryState, parseAsString } from 'nuqs'
export default function SearchBox() {
const [query, setQuery] = useQueryState('q', parseAsString.withDefault(''))
// history: 'replace' (default)
// Typing "react" doesn't create 5 history entries
return (
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
/>
)
}Incorrect (push for typing):
'use client'
import { useQueryState, parseAsString } from 'nuqs'
export default function SearchBox() {
const [query, setQuery] = useQueryState('q', parseAsString.withDefault('').withOptions({
history: 'push' // Don't do this!
}))
// Typing "react" creates entries: r, re, rea, reac, react
// Back button is unusable
return (
<input
value={query}
onChange={e => setQuery(e.target.value)}
/>
)
}Typical use cases for history:replace (default):
- Search input text
- Slider/range values
- Real-time filter changes
- Sort order selection
- Any rapidly-changing state
Hybrid pattern (replace during typing, push on submit):
'use client'
import { useState } from 'react'
import { useQueryState, parseAsString } from 'nuqs'
export default function SearchForm() {
const [query, setQuery] = useQueryState('q', parseAsString.withDefault(''))
const [input, setInput] = useState(query)
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
setQuery(input, { history: 'push' }) // Push on explicit submit
}
return (
<form onSubmit={handleSubmit}>
<input value={input} onChange={e => setInput(e.target.value)} />
<button type="submit">Search</button>
</form>
)
}Reference: nuqs History Option
Control Scroll Behavior on URL Changes
By default, nuqs doesn't scroll on URL changes. Use the scroll option to control whether state changes scroll to the top of the page.
Incorrect (unwanted scroll on filter change):
'use client'
import { useQueryState, parseAsString } from 'nuqs'
export default function FilterPanel() {
const [filter, setFilter] = useQueryState('filter', parseAsString.withDefault('').withOptions({
scroll: true // Bad for filters - user loses their place!
}))
return (
<select value={filter} onChange={e => setFilter(e.target.value)}>
<option value="">All</option>
<option value="active">Active</option>
</select>
)
}Correct (no scroll for filters, scroll for pagination):
'use client'
import { useQueryState, parseAsString, parseAsInteger } from 'nuqs'
export default function SearchPage() {
// No scroll for filters - user stays in place
const [filter, setFilter] = useQueryState('filter', parseAsString.withDefault(''))
// Scroll for pagination - user sees new content from top
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1).withOptions({
scroll: true,
history: 'push'
}))
return (
<div>
<select value={filter} onChange={e => setFilter(e.target.value)}>
<option value="">All</option>
<option value="active">Active</option>
</select>
<button onClick={() => setPage(p => p + 1)}>Next Page</button>
</div>
)
}Reference: nuqs Scroll Option
Choose Correct Array Parser Format
nuqs offers two array formats with different URL representations. Choose based on your backend API expectations and URL readability requirements.
Incorrect (wrong format for backend):
'use client'
import { useQueryState, parseAsArrayOf, parseAsString } from 'nuqs'
export default function TagFilter() {
const [tags, setTags] = useQueryState(
'tags',
parseAsArrayOf(parseAsString).withDefault([])
)
// URL: ?tags=react,nextjs
// But backend expects: ?tag=react&tag=nextjs
// API receives single string "react,nextjs" instead of array!
}Correct (match backend expectations):
'use client'
import { useQueryState, parseAsNativeArrayOf, parseAsString } from 'nuqs'
export default function TagFilter() {
const [tags, setTags] = useQueryState(
'tag', // Note: singular key name
parseAsNativeArrayOf(parseAsString).withDefault([])
)
// URL: ?tag=react&tag=nextjs
// Backend correctly receives array ['react', 'nextjs']
return (
<div>
Tags: {tags.join(', ')}
<button onClick={() => setTags([...tags, 'new-tag'])}>Add</button>
</div>
)
}When to use each:
| Format | URL Example | Use When | Min version |
|---|---|---|---|
parseAsArrayOf | ?ids=1,2,3 | Compact URLs, numeric IDs, custom backends | All versions |
parseAsNativeArrayOf | ?tag=a&tag=b | Standard form encoding, PHP/Rails backends | v2.7+ (MultiParsers) |
If you're on nuqs < 2.7, use parseAsArrayOf with a separator change (e.g., .withOptions({ ... })) or upgrade — there is no shim.
Reference: nuqs Array Parsers
Select Appropriate Date Parser
nuqs provides three date parsers with different URL formats and precision. Choose based on your requirements for time precision and URL readability.
Incorrect (wrong parser for use case):
'use client'
import { useQueryState, parseAsIsoDateTime } from 'nuqs'
export default function DateRangePicker() {
const [startDate, setStartDate] = useQueryState('start', parseAsIsoDateTime)
// URL: ?start=2024-01-01T00:00:00.000Z
// Problem: User selected "Jan 1" but URL shows timezone complexity
// Better: Use parseAsIsoDate for date-only pickers
return (
<input
type="date"
value={startDate?.toISOString().slice(0, 10) ?? ''}
onChange={e => setStartDate(new Date(e.target.value))}
/>
)
}Correct (match parser to use case):
'use client'
import { useQueryState, parseAsIsoDate, parseAsTimestamp } from 'nuqs'
export default function DateRangePicker() {
// For date-only picker: clean URL
const [startDate, setStartDate] = useQueryState('start', parseAsIsoDate)
// URL: ?start=2024-01-01
// For precise timestamps: use parseAsTimestamp
const [lastModified, setLastModified] = useQueryState('modified', parseAsTimestamp)
// URL: ?modified=1704067200000
return (
<input
type="date"
value={startDate?.toISOString().slice(0, 10) ?? ''}
onChange={e => setStartDate(new Date(e.target.value))}
/>
)
}When to use each:
| Parser | URL Format | Use Case |
|---|---|---|
parseAsTimestamp | 1704067200000 | Precise timestamps, API integration |
parseAsIsoDateTime | 2024-01-01T12:00:00.000Z | Debugging, shareable URLs with time |
parseAsIsoDate | 2024-01-01 | Date pickers, calendar views |
Reference: nuqs Date Parsers
Use Enum Parsers for Constrained Values
When state should only accept specific values (like status, sort direction, or view mode), use enum or literal parsers. This prevents invalid values from URL tampering and provides type safety.
Incorrect (accepts any string):
'use client'
import { useQueryState } from 'nuqs'
type SortOrder = 'asc' | 'desc'
export default function SortableList() {
const [sort, setSort] = useQueryState('sort')
// sort is string | null - accepts ANY value
// URL: ?sort=malicious works silently
const sortOrder = sort as SortOrder // Unsafe cast!
return (
<select
value={sort ?? 'asc'}
onChange={e => setSort(e.target.value)}
>
<option value="asc">Ascending</option>
<option value="desc">Descending</option>
</select>
)
}Correct (validated enum):
'use client'
import { useQueryState, parseAsStringLiteral } from 'nuqs'
const sortOrders = ['asc', 'desc'] as const
export default function SortableList() {
const [sort, setSort] = useQueryState(
'sort',
parseAsStringLiteral(sortOrders).withDefault('asc')
)
// sort is 'asc' | 'desc' - invalid values return null/default
// URL: ?sort=malicious → falls back to 'asc'
return (
<select value={sort} onChange={e => setSort(e.target.value as typeof sort)}>
<option value="asc">Ascending</option>
<option value="desc">Descending</option>
</select>
)
}Alternative (TypeScript enum):
import { parseAsStringEnum } from 'nuqs'
enum Status {
Active = 'active',
Inactive = 'inactive',
Pending = 'pending'
}
const [status, setStatus] = useQueryState(
'status',
parseAsStringEnum<Status>(Object.values(Status)).withDefault(Status.Active)
)Reference: nuqs Enum Parsers
Use parseAsHex for Color Values
When storing color values in URLs, parseAsHex provides cleaner hexadecimal representation instead of decimal numbers.
Incorrect (decimal in URL):
'use client'
import { useQueryState, parseAsInteger } from 'nuqs'
export default function ColorPicker() {
const [color, setColor] = useQueryState(
'color',
parseAsInteger.withDefault(0xff0000)
)
// URL: ?color=16711680 (not human-readable)
const hexString = color.toString(16).padStart(6, '0')
return (
<input
type="color"
value={`#${hexString}`}
onChange={e => setColor(parseInt(e.target.value.slice(1), 16))}
/>
)
}Correct (hex in URL):
'use client'
import { useQueryState, parseAsHex } from 'nuqs'
export default function ColorPicker() {
const [color, setColor] = useQueryState(
'color',
parseAsHex.withDefault(0xff0000)
)
// URL: ?color=ff0000 (human-readable)
// State: 16711680 (number for calculations)
const hexString = color.toString(16).padStart(6, '0')
return (
<input
type="color"
value={`#${hexString}`}
onChange={e => setColor(parseInt(e.target.value.slice(1), 16))}
/>
)
}Benefits:
- URLs are readable:
?color=ff0000vs?color=16711680 - State is numeric for calculations
- Standard hex color format in URL
Reference: nuqs parseAsHex
Use parseAsIndex for 1-Based URL Display
Arrays are 0-indexed in JavaScript, but users expect 1-indexed URLs (page 1, item 1). parseAsIndex automatically converts between them.
Incorrect (manual conversion):
'use client'
import { useQueryState, parseAsInteger } from 'nuqs'
export default function Pagination() {
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1))
// URL: ?page=1
// Array index: need page - 1 everywhere
const items = ['a', 'b', 'c', 'd', 'e']
const currentItem = items[page - 1] // Manual conversion
return (
<div>
<p>Page {page}: {currentItem}</p>
<button onClick={() => setPage(p => p + 1)}>Next</button>
</div>
)
}Correct (automatic conversion):
'use client'
import { useQueryState, parseAsIndex } from 'nuqs'
export default function Pagination() {
const [pageIndex, setPageIndex] = useQueryState(
'page',
parseAsIndex.withDefault(0)
)
// URL: ?page=1 (user-friendly, 1-indexed)
// State: 0 (code-friendly, 0-indexed)
const items = ['a', 'b', 'c', 'd', 'e']
const currentItem = items[pageIndex] // Direct array access
return (
<div>
<p>Page {pageIndex + 1}: {currentItem}</p>
<button onClick={() => setPageIndex(i => i + 1)}>Next</button>
</div>
)
}How it works:
- URL
?page=1→ State0 - URL
?page=5→ State4 - State
0→ URL?page=1 - State
4→ URL?page=5
Benefits:
- No off-by-one bugs
- Array indices work directly
- URLs are human-friendly
Reference: nuqs parseAsIndex
Validate JSON Parser Input
parseAsJson requires a validator function (this is a hard requirement in nuqs v2 — calling parseAsJson<T>() with no argument is a TypeScript error). The validator must return the typed value when valid and either throw or return null when invalid. Avoid the "make the type errors go away" shortcut of an unchecked cast — URL params are attacker-controlled input, and an unchecked cast lets any shape into your app.
Incorrect (unchecked cast as the validator):
'use client'
import { useQueryState, parseAsJson } from 'nuqs'
interface Filters {
minPrice: number
maxPrice: number
categories: string[]
}
export default function FilterPanel() {
const [filters, setFilters] = useQueryState(
'filters',
parseAsJson((v) => v as Filters) // Cast — no runtime check
)
// URL: ?filters={"minPrice":"haha"} → filters.minPrice is "haha", not a number
// URL: ?filters=notjson → null, but downstream code expecting a string crashes
return <div>Min: {filters?.minPrice.toFixed(2)}</div>
}Correct (hand-rolled type guard):
'use client'
import { useQueryState, parseAsJson } from 'nuqs'
interface Filters {
minPrice: number
maxPrice: number
categories: string[]
}
function isFilters(value: unknown): value is Filters {
if (!value || typeof value !== 'object') return false
const obj = value as Record<string, unknown>
return (
typeof obj.minPrice === 'number' &&
typeof obj.maxPrice === 'number' &&
Array.isArray(obj.categories) &&
obj.categories.every((c) => typeof c === 'string')
)
}
export default function FilterPanel() {
const [filters] = useQueryState(
'filters',
parseAsJson<Filters>((value) => (isFilters(value) ? value : null)).withDefault({
minPrice: 0,
maxPrice: 1000,
categories: []
})
)
// Invalid JSON → null → falls back to default
return <div>Price: {filters.minPrice} – {filters.maxPrice}</div>
}Correct (Standard Schema — Zod, Valibot, ArkType, Effect Schema):
Since nuqs v2.5 the validator slot accepts any Standard Schema parser directly — no safeParse(...).success ? value : null wrapper required. Zod's .parse throws on invalid input, which nuqs catches and converts to null.
import { z } from 'zod'
const FiltersSchema = z.object({
minPrice: z.number(),
maxPrice: z.number(),
categories: z.array(z.string())
})
const [filters] = useQueryState(
'filters',
parseAsJson(FiltersSchema.parse).withDefault({
minPrice: 0,
maxPrice: 1000,
categories: []
})
)When NOT to use this pattern:
- For non-object types, prefer typed primitive parsers (
parseAsInteger,parseAsStringEnum,parseAsArrayOf) — they're cheaper and the validator is implicit.
Reference: nuqs Built-in Parsers
Use Typed Parsers for Non-String Values
URL query parameters are always strings. Without typed parsers, you'll get string values where you expect numbers or booleans, causing type errors and incorrect comparisons.
Incorrect (string instead of number):
'use client'
import { useQueryState } from 'nuqs'
export default function Pagination() {
const [page, setPage] = useQueryState('page')
// page is string | null, not number
// page + 1 = "11" not 2 when page is "1"
return (
<button onClick={() => setPage(String(Number(page) + 1))}>
Next Page
</button>
)
}Correct (typed parser):
'use client'
import { useQueryState, parseAsInteger } from 'nuqs'
export default function Pagination() {
const [page, setPage] = useQueryState('page', parseAsInteger)
// page is number | null
// Arithmetic works correctly
return (
<button onClick={() => setPage((p) => (p ?? 0) + 1)}>
Next Page
</button>
)
}Available parsers:
parseAsInteger- integersparseAsFloat- decimal numbersparseAsBoolean- true/falseparseAsTimestamp- Date from millisecondsparseAsIsoDateTime- Date from ISO stringparseAsJson<T>()- JSON objects
Reference: nuqs Parsers Documentation
Use withDefault for Non-Nullable State
Without withDefault, query state is always nullable (T | null). This forces null checks everywhere the value is used. Use withDefault to provide a fallback value and get non-nullable types.
Incorrect (nullable state, null checks everywhere):
'use client'
import { useQueryState, parseAsInteger } from 'nuqs'
export default function Counter() {
const [count, setCount] = useQueryState('count', parseAsInteger)
// count is number | null
return (
<div>
{/* Null check required */}
<p>Count: {count ?? 0}</p>
{/* Null check required */}
<button onClick={() => setCount((count ?? 0) + 1)}>
Increment
</button>
</div>
)
}Correct (non-nullable with default):
'use client'
import { useQueryState, parseAsInteger } from 'nuqs'
export default function Counter() {
const [count, setCount] = useQueryState(
'count',
parseAsInteger.withDefault(0)
)
// count is number (never null)
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>
Increment
</button>
</div>
)
}Benefits:
- TypeScript infers non-nullable type
- No null coalescing needed
- Functional updates work without null checks
- Default appears in URL only when
clearOnDefault: false
Reference: nuqs withDefault
Memoize Components Using URL State
When URL state changes, components subscribed to that state re-render. On Next.js, every useQueryState/useQueryStates consumer in the subtree re-renders on any URL change because URLSearchParams is provided through a single context. Use React.memo and confine hook usage to leaf components to avoid cascading re-renders.
On non-Next.js adapters (React SPA, React Router, Remix, TanStack Router), nuqs v2.5 added key isolation: a hook only re-renders when its specific key changes. The memoization advice below is still useful but far less critical on those frameworks — see perf-key-isolation.
Incorrect (entire page re-renders on every Next.js URL change):
'use client'
import { useQueryState, parseAsString } from 'nuqs'
export default function SearchPage() {
const [query, setQuery] = useQueryState('q', parseAsString.withDefault(''))
return (
<div>
<SearchInput query={query} setQuery={setQuery} />
<ExpensiveSidebar /> {/* Re-renders on every query change */}
<ResultsList query={query} />
</div>
)
}
function ExpensiveSidebar() {
// Heavy computation that doesn't need query
return <aside>…</aside>
}Correct (memoize unrelated subtrees):
'use client'
import { memo } from 'react'
import { useQueryState, parseAsString } from 'nuqs'
export default function SearchPage() {
const [query, setQuery] = useQueryState('q', parseAsString.withDefault(''))
return (
<div>
<SearchInput query={query} setQuery={setQuery} />
<ExpensiveSidebar /> {/* Memoized — bypasses re-render when query changes */}
<ResultsList query={query} />
</div>
)
}
const ExpensiveSidebar = memo(function ExpensiveSidebar() {
return <aside>…</aside>
})Better (hoist the hook into a leaf — works on all adapters):
'use client'
import { useQueryState, parseAsString } from 'nuqs'
function SearchSection() {
const [query, setQuery] = useQueryState('q', parseAsString.withDefault(''))
return (
<>
<SearchInput query={query} setQuery={setQuery} />
<ResultsList query={query} />
</>
)
}
export default function SearchPage() {
return (
<div>
<SearchSection /> {/* Only this subtree re-renders on URL change */}
<ExpensiveSidebar /> {/* Not affected */}
</div>
)
}When NOT to use this pattern:
- Non-Next.js adapters where the consumer is already a leaf — key isolation makes memoization unnecessary noise.
Reference: React memo
Use clearOnDefault for Clean URLs
By default, nuqs removes parameters from the URL when they match the default value. This keeps URLs clean. Set clearOnDefault: false only when you need the parameter always visible.
Incorrect (default always shown in URL):
'use client'
import { useQueryState, parseAsInteger } from 'nuqs'
export default function Pagination() {
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1).withOptions({
clearOnDefault: false // Unnecessary!
}))
// URL always shows ?page=1 even on first page
// Clutters shareable URLs, hurts SEO
return <button onClick={() => setPage(1)}>First</button>
}Correct (clean URLs by default):
'use client'
import { useQueryState, parseAsInteger } from 'nuqs'
export default function Pagination() {
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1))
// clearOnDefault: true (default)
// page=1: URL is /search (clean)
// page=2: URL is /search?page=2
return (
<div>
<button onClick={() => setPage(1)}>First</button>
<button onClick={() => setPage(p => p + 1)}>Next</button>
</div>
)
}When clearOnDefault: false is appropriate:
- Analytics tracking requires all parameters
- API expects explicit parameter even for default
- Debugging where you need to see all state
Reference: nuqs clearOnDefault
Debounce Search Input Before URL Update
For search inputs that drive a server fetch (shallow: false), debounce the URL write so the server only re-renders once the user stops typing. Since nuqs v2.5, debounce is built in — pass limitUrlUpdates: debounce(N) instead of hand-rolling a setTimeout dance with a parallel local-state mirror.
Incorrect (a server request per keystroke):
'use client'
import { useQueryState, parseAsString } from 'nuqs'
export default function SearchBox() {
const [query, setQuery] = useQueryState(
'q',
parseAsString.withDefault('').withOptions({
shallow: false // Every keystroke triggers a server re-render
})
)
return <input value={query} onChange={(e) => setQuery(e.target.value)} />
}Correct (built-in debounce — one request per pause):
'use client'
import { useQueryState, parseAsString, debounce } from 'nuqs'
import { useTransition } from 'react'
export default function SearchBox() {
const [isLoading, startTransition] = useTransition()
const [query, setQuery] = useQueryState(
'q',
parseAsString.withDefault('').withOptions({
shallow: false,
startTransition,
limitUrlUpdates: debounce(300) // Server request fires 300ms after last keystroke
})
)
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
{isLoading && <span>Searching…</span>}
</>
)
}Why this beats the manual `setTimeout` pattern:
- One source of truth —
queryis both the displayed value and the in-flight value; no paralleluseStatemirror. - The Promise returned by the setter still resolves to the merged
URLSearchParamsafter the debounce fires, so analytics/share flows keep working. - nuqs v2.6 emits a warning if you combine
shallow: truewith debounce —shallow: truealready keeps the URL local, so debouncing it is almost always a bug. Honour the warning.
Alternative (no URL-driven fetch — useDeferredValue is fine):
When the search is purely client-side (shallow: true, the default), React's useDeferredValue keeps the input responsive without touching nuqs options:
'use client'
import { useDeferredValue } from 'react'
import { useQueryState, parseAsString } from 'nuqs'
export default function ClientSearch() {
const [query, setQuery] = useQueryState('q', parseAsString.withDefault(''))
const deferredQuery = useDeferredValue(query)
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<Results query={deferredQuery} />
</>
)
}When NOT to use this pattern:
- The setter only fires on submit/blur — there's nothing to debounce.
- You want to rate-limit continuous input (slider drags, scrub bars) —
throttle()keeps intermediate values;debounce()discards them. Seeperf-throttle-updates.
Reference: nuqs Options
Rely on Key Isolation Outside Next.js
nuqs v2.5 added key isolation on every non-Next.js adapter (Plain React, React Router v5/v6/v7, Remix, TanStack Router). A useQueryState('foo', …) consumer there only re-renders when `foo` changes — unrelated URL key changes are filtered out by the adapter's subscription model. On those frameworks, the manual memo/leaf-component gymnastics from perf-avoid-rerender are usually unnecessary.
Next.js still re-renders the whole subtree on any URL change because URLSearchParams is delivered via a single root context. Treat Next.js as the exception.
Incorrect (over-memoising on a key-isolated adapter):
// React Router v7 app
import { memo } from 'react'
import { useQueryState, parseAsString } from 'nuqs'
const Tags = memo(function Tags() {
const [tags] = useQueryState('tags', parseAsString.withDefault(''))
return <TagBadges tags={tags} />
})
const Sort = memo(function Sort() {
const [sort] = useQueryState('sort', parseAsString.withDefault('desc'))
return <SortPicker value={sort} />
})
// `memo` is redundant — Tags never re-renders when `sort` changes,
// and vice versa. Adapter already isolates by key.Correct (let the adapter do the work):
// React Router v7 app
import { useQueryState, parseAsString } from 'nuqs'
function Tags() {
const [tags] = useQueryState('tags', parseAsString.withDefault(''))
return <TagBadges tags={tags} />
}
function Sort() {
const [sort] = useQueryState('sort', parseAsString.withDefault('desc'))
return <SortPicker value={sort} />
}
// Tags re-renders only on `tags` changes, Sort only on `sort` — no wrappers needed.Practical rule of thumb:
| Adapter | Re-render scope per useQueryState | Manual memoization usually worth it? |
|---|---|---|
| Next.js (App + Pages) | Whole consumer subtree on any URL change | Yes (see perf-avoid-rerender) |
| Plain React | Only when subscribed key changes | No |
| React Router v5/v6/v7 | Only when subscribed key changes | No |
| Remix | Only when subscribed key changes | No |
| TanStack Router | Only when subscribed key changes | No |
Caveat: useQueryStates (the plural form) intentionally subscribes to every key in its object, so it re-renders whenever any of those keys change — that is its contract. If you want per-key isolation, prefer multiple useQueryState hooks.
When NOT to use this pattern:
- You're on Next.js — apply
perf-avoid-rerenderinstead. - You're using
useQueryStatesfor atomic updates and accept the coarser subscription. Seestate-use-query-statesfor the tradeoff.
Reference: nuqs 2.5 release notes
Use createSerializer for Link URLs
When generating URLs for links or navigation without needing state, use createSerializer. This avoids unnecessary hook usage and works in Server Components.
Incorrect (hook for URL generation only):
'use client'
import { useQueryState, parseAsInteger } from 'nuqs'
export default function PaginationLinks({ totalPages }: { totalPages: number }) {
const [page] = useQueryState('page', parseAsInteger.withDefault(1))
// Using state just to generate URLs
return (
<nav>
{Array.from({ length: totalPages }, (_, i) => (
<a key={i} href={`?page=${i + 1}`}>
{i + 1}
</a>
))}
</nav>
)
}Correct (serializer utility):
// lib/searchParams.ts
import { createSerializer, parseAsInteger, parseAsString } from 'nuqs/server'
export const searchParams = {
q: parseAsString,
page: parseAsInteger.withDefault(1)
}
export const serialize = createSerializer(searchParams)
// components/PaginationLinks.tsx (can be Server Component)
import { serialize } from '@/lib/searchParams'
export default function PaginationLinks({ totalPages }: { totalPages: number }) {
return (
<nav>
{Array.from({ length: totalPages }, (_, i) => (
<a key={i} href={`?${serialize({ page: i + 1 })}`}>
{i + 1}
</a>
))}
</nav>
)
}Building on existing URL:
import { serialize } from '@/lib/searchParams'
// Preserve existing params, change page
const currentParams = { q: 'react', page: 1 }
const nextPageUrl = `?${serialize({ ...currentParams, page: 2 })}`
// Result: ?q=react&page=2With base URL:
const url = serialize('/search', { q: 'react', page: 1 })
// Result: /search?q=react&page=1Reference: nuqs createSerializer
Throttle Rapid URL Updates
Browsers rate-limit History API calls. Rapid updates (typing, sliders, dragging) can exceed this limit, causing dropped updates and console warnings. Pass limitUrlUpdates: throttle(N) to coalesce URL writes — local state continues to update instantly, the URL writes at most every N ms.
The legacy throttleMs: N option was deprecated in nuqs v2.5 and removed from new code paths; prefer limitUrlUpdates going forward.
Incorrect (every keystroke pushes to history):
'use client'
import { useQueryState, parseAsString } from 'nuqs'
export default function SearchBox() {
const [query, setQuery] = useQueryState('q', parseAsString.withDefault(''))
// Every keystroke writes to history → browser may throttle after ~100 rapid updates
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
)
}Correct (throttle URL updates):
'use client'
import { useQueryState, parseAsString, throttle } from 'nuqs'
export default function SearchBox() {
const [query, setQuery] = useQueryState(
'q',
parseAsString.withDefault('').withOptions({
limitUrlUpdates: throttle(300) // URL flushed at most every 300ms
})
)
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
)
}For sliders and drag operations (tighter window):
'use client'
import { useQueryState, parseAsInteger, throttle } from 'nuqs'
export default function VolumeSlider() {
const [volume, setVolume] = useQueryState(
'volume',
parseAsInteger.withDefault(50).withOptions({
limitUrlUpdates: throttle(100) // More responsive for continuous input
})
)
return (
<input
type="range" min={0} max={100}
value={volume}
onChange={(e) => setVolume(Number(e.target.value))}
/>
)
}Force an immediate (non-throttled) flush on a single call:
import { defaultRateLimit } from 'nuqs'
// Normal updates use the throttle window
setQuery('intermediate')
// On blur, bypass rate limiting and commit immediately
setQuery('final value', { limitUrlUpdates: defaultRateLimit })When NOT to use this pattern:
- Search inputs that drive a server fetch (
shallow: false) — preferdebounce()so the request only fires once the user stops typing. Seeperf-debounce-search. - One-off updates (button clicks, form submits) — there's nothing to throttle.
Note: UI state from the hook updates synchronously regardless of limitUrlUpdates; only the URL write is rate-limited.
Reference: nuqs Options
Handle Async searchParams in Next.js 15+
In Next.js 15+, searchParams is a Promise that must be awaited. Using it directly without await causes TypeScript errors and runtime issues.
Incorrect (Next.js 15+ without await):
// app/search/page.tsx
import { searchParamsCache } from '@/lib/searchParams'
type PageProps = {
searchParams: { q?: string } // Wrong type for Next.js 15+
}
export default async function SearchPage({ searchParams }: PageProps) {
// searchParams is a Promise, not an object
const { q } = searchParamsCache.parse(searchParams) // Type error
}Correct (Next.js 15+):
// app/search/page.tsx
import { searchParamsCache } from '@/lib/searchParams'
import type { SearchParams } from 'nuqs/server'
type PageProps = {
searchParams: Promise<SearchParams> // Correct type
}
export default async function SearchPage({ searchParams }: PageProps) {
// Await the Promise
const { q, page } = await searchParamsCache.parse(searchParams)
return <Results query={q} page={page} />
}For Next.js 14 and earlier:
// app/search/page.tsx
import { searchParamsCache } from '@/lib/searchParams'
type PageProps = {
searchParams: Record<string, string | string[] | undefined>
}
export default function SearchPage({ searchParams }: PageProps) {
// No await needed in Next.js 14
const { q, page } = searchParamsCache.parse(searchParams)
return <Results query={q} page={page} />
}Version-agnostic pattern:
import type { SearchParams } from 'nuqs/server'
type PageProps = {
searchParams: Promise<SearchParams>
}
export default async function SearchPage({ searchParams }: PageProps) {
// Works in both Next.js 14 and 15
const { q } = await searchParamsCache.parse(searchParams)
}Reference: Next.js 15 Migration
Call parse() Before get() in Server Components
createSearchParamsCache requires calling parse() at the page level before calling get() in nested components. Forgetting parse() causes get() to return undefined or throw.
Incorrect (missing parse):
// app/search/page.tsx
import { ResultsHeader } from './ResultsHeader'
export default async function SearchPage({ searchParams }) {
// Missing: await searchParamsCache.parse(searchParams)
return (
<div>
<ResultsHeader /> {/* Will fail or return undefined */}
<Results />
</div>
)
}
// components/ResultsHeader.tsx
import { searchParamsCache } from '@/lib/searchParams'
export function ResultsHeader() {
const query = searchParamsCache.get('q') // Error: cache not initialized
return <h1>Results for {query}</h1>
}Correct (parse at page level):
// app/search/page.tsx
import { searchParamsCache } from '@/lib/searchParams'
import { ResultsHeader } from './ResultsHeader'
import type { SearchParams } from 'nuqs/server'
type PageProps = {
searchParams: Promise<SearchParams>
}
export default async function SearchPage({ searchParams }: PageProps) {
// Parse FIRST, before rendering any children
await searchParamsCache.parse(searchParams)
return (
<div>
<ResultsHeader /> {/* Now get() works */}
<Results />
</div>
)
}
// components/ResultsHeader.tsx
import { searchParamsCache } from '@/lib/searchParams'
export function ResultsHeader() {
const query = searchParamsCache.get('q') // Works correctly
return <h1>Results for {query}</h1>
}Parse and destructure in one step:
export default async function SearchPage({ searchParams }: PageProps) {
const { q, page } = await searchParamsCache.parse(searchParams)
// Use q and page directly, or let children use get()
return <Results query={q} page={page} />
}Reference: nuqs Server Cache
Use createSearchParamsCache for Server Components
In Server Components, use createSearchParamsCache to access URL parameters with type safety. This avoids prop drilling and provides the same parsers as client-side hooks.
Incorrect (manual parsing):
// app/search/page.tsx
type PageProps = {
searchParams: Promise<{ q?: string; page?: string }>
}
export default async function SearchPage({ searchParams }: PageProps) {
const params = await searchParams
const query = params.q ?? ''
const page = parseInt(params.page ?? '1', 10) // Manual parsing
// No type safety, parsing logic duplicated
return <Results query={query} page={page} />
}Correct (search params cache):
// lib/searchParams.ts
import {
createSearchParamsCache,
parseAsString,
parseAsInteger
} from 'nuqs/server'
export const searchParamsCache = createSearchParamsCache({
q: parseAsString.withDefault(''),
page: parseAsInteger.withDefault(1)
})
// app/search/page.tsx
import { searchParamsCache } from '@/lib/searchParams'
import type { SearchParams } from 'nuqs/server'
type PageProps = {
searchParams: Promise<SearchParams>
}
export default async function SearchPage({ searchParams }: PageProps) {
// Parse once at page level
const { q, page } = await searchParamsCache.parse(searchParams)
return <Results query={q} page={page} />
}Access in nested Server Components:
// components/ResultsHeader.tsx
import { searchParamsCache } from '@/lib/searchParams'
export function ResultsHeader() {
// No props needed - access from cache
const query = searchParamsCache.get('q')
const page = searchParamsCache.get('page')
return <h1>Results for "{query}" (Page {page})</h1>
}Important: Call parse() once at the page level before using get() in nested components.
Alternative: `createLoader` for non-nested cases. If you only consume the parsed values inside the page itself (no deeply nested Server Components needing get()), createLoader is the lighter primitive — it returns a single function and skips the React-cache plumbing. Use the cache when you'd otherwise prop-drill; use the loader when you wouldn't.
// lib/searchParams.ts
import { createLoader, parseAsString, parseAsInteger } from 'nuqs/server'
export const loadSearchParams = createLoader({
q: parseAsString.withDefault(''),
page: parseAsInteger.withDefault(1)
})
// app/search/page.tsx
import { loadSearchParams } from '@/lib/searchParams'
export default async function SearchPage({ searchParams }) {
const { q, page } = await loadSearchParams(searchParams)
return <Results query={q} page={page} />
}The same parser map can power both createSearchParamsCache and createLoader (and a client-side useQueryStates), so the choice is purely about ergonomics — see server-share-parsers.
Reference: nuqs Server-Side
Use shallow:false to Trigger Server Re-renders
By default, nuqs updates are client-side only (shallow: true). Set shallow: false to trigger Server Component re-renders when URL changes, enabling server-side data fetching.
Incorrect (server data never refreshes):
'use client'
import { useQueryState, parseAsInteger } from 'nuqs'
export default function Pagination() {
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1))
// shallow: true (default) - server doesn't see URL changes
// Server-fetched data stays stale
return <button onClick={() => setPage(p => p + 1)}>Next</button>
}Correct (server refetches on change):
'use client'
import { useQueryState, parseAsInteger } from 'nuqs'
export default function Pagination() {
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1).withOptions({
shallow: false // Notify server of URL changes
}))
// Server Components re-render with new page value
return <button onClick={() => setPage(p => p + 1)}>Next</button>
}With loading state using useTransition:
'use client'
import { useTransition } from 'react'
import { useQueryState, parseAsInteger } from 'nuqs'
export default function Pagination() {
const [isLoading, startTransition] = useTransition()
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1).withOptions({
shallow: false,
startTransition // Shows loading during server fetch
}))
return (
<div>
{isLoading && <span>Loading...</span>}
<button onClick={() => setPage(p => p + 1)} disabled={isLoading}>
Next
</button>
</div>
)
}When to use shallow:false:
- Pagination with server-fetched data
- Search that triggers server queries
- Filters that affect server-rendered content
- Any state that affects Server Component output
Reference: nuqs Shallow Option
Share Parsers Between Client and Server
Define parsers once and reuse them in both createSearchParamsCache (server) and useQueryState (client). This ensures consistent parsing behavior and prevents bugs from mismatched configurations.
Incorrect (separate definitions):
// app/search/page.tsx (Server)
import { createSearchParamsCache, parseAsInteger } from 'nuqs/server'
const cache = createSearchParamsCache({
page: parseAsInteger.withDefault(1)
})
// components/Pagination.tsx (Client)
'use client'
import { useQueryState, parseAsInteger } from 'nuqs'
export function Pagination() {
// Different default - inconsistent!
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(0))
}Correct (shared definition):
// lib/searchParams.ts
import { parseAsInteger, parseAsString } from 'nuqs' // Works for both
export const searchParamsParsers = {
q: parseAsString.withDefault(''),
page: parseAsInteger.withDefault(1),
limit: parseAsInteger.withDefault(10)
}
// lib/searchParams.server.ts
import { createSearchParamsCache } from 'nuqs/server'
import { searchParamsParsers } from './searchParams'
export const searchParamsCache = createSearchParamsCache(searchParamsParsers)
// app/search/page.tsx (Server)
import { searchParamsCache } from '@/lib/searchParams.server'
export default async function SearchPage({ searchParams }) {
const { q, page } = await searchParamsCache.parse(searchParams)
return <Results query={q} page={page} />
}
// components/Pagination.tsx (Client)
'use client'
import { useQueryState } from 'nuqs'
import { searchParamsParsers } from '@/lib/searchParams'
export function Pagination() {
const [page, setPage] = useQueryState('page', searchParamsParsers.page)
// Uses same parser with same default as server
}Benefits:
- Single source of truth for defaults
- TypeScript catches inconsistencies
- Easier to update configuration
Reference: nuqs Documentation
Integrate useTransition for Loading States
When using shallow: false, integrate React's useTransition to track when the server is fetching new data. This enables loading indicators during URL-triggered server updates.
Incorrect (no loading feedback):
'use client'
import { useQueryState, parseAsString } from 'nuqs'
export default function SearchBox() {
const [query, setQuery] = useQueryState('q', parseAsString.withDefault('').withOptions({
shallow: false
}))
// User types, waits with no feedback while server fetches
return (
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
/>
)
}Correct (loading state):
'use client'
import { useTransition } from 'react'
import { useQueryState, parseAsString } from 'nuqs'
export default function SearchBox() {
const [isLoading, startTransition] = useTransition()
const [query, setQuery] = useQueryState('q', parseAsString.withDefault('').withOptions({
shallow: false,
startTransition
}))
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
/>
{isLoading && <span className="spinner" />}
</div>
)
}With disabled interaction during load:
'use client'
import { useTransition } from 'react'
import { useQueryStates, parseAsString, parseAsInteger } from 'nuqs'
export default function FilterPanel() {
const [isLoading, startTransition] = useTransition()
const [filters, setFilters] = useQueryStates(
{
category: parseAsString.withDefault(''),
page: parseAsInteger.withDefault(1)
},
{
shallow: false,
startTransition
}
)
return (
<fieldset disabled={isLoading}>
<select
value={filters.category}
onChange={e => setFilters({ category: e.target.value, page: 1 })}
>
<option value="">All</option>
<option value="electronics">Electronics</option>
</select>
{isLoading && <p>Updating results...</p>}
</fieldset>
)
}Reference: nuqs useTransition Integration
Configure App-Wide Defaults on NuqsAdapter
Since nuqs v2.5, NuqsAdapter accepts a defaultOptions prop. Setting history, shallow, scroll, clearOnDefault, limitUrlUpdates, or startTransition once on the adapter eliminates the temptation to copy-paste the same .withOptions({ … }) chain onto every parser, and reduces the chance one component silently disagrees with the rest of the app (e.g. one missing shallow: false that drops a server fetch).
Incorrect (every parser repeats the same options):
// Search page
const [q] = useQueryState('q', parseAsString.withDefault('').withOptions({ shallow: false, scroll: false }))
const [page] = useQueryState('page', parseAsInteger.withDefault(1).withOptions({ shallow: false, scroll: false }))
// Filters page
const [tag] = useQueryState('tag', parseAsString.withDefault('').withOptions({ shallow: false /* forgot scroll: false */ }))
// Inconsistency: filter changes now scroll to top; search changes don't.Correct (set defaults once on the adapter):
// app/layout.tsx
import { NuqsAdapter } from 'nuqs/adapters/next/app'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<NuqsAdapter
defaultOptions={{
shallow: false, // Every URL change re-runs the server
scroll: false // Never auto-scroll on URL update
}}
>
{children}
</NuqsAdapter>
</body>
</html>
)
}// Anywhere in the tree — no per-parser duplication
const [q] = useQueryState('q', parseAsString.withDefault(''))
const [page] = useQueryState('page', parseAsInteger.withDefault(1))
// Both inherit shallow: false, scroll: false from the adapterPer-hook overrides still work:
// A purely-local UI flag — opt out of the server round-trip just here
const [drawerOpen, setDrawerOpen] = useQueryState(
'drawer',
parseAsBoolean.withDefault(false).withOptions({ shallow: true })
)Precedence (low → high): built-in defaults → adapter defaultOptions → parser .withOptions(…) → per-call setX(value, { … }).
When NOT to use this pattern:
- The app has fewer than a handful of nuqs hooks — duplicating options is fine.
- Different sections of the app legitimately want different defaults (e.g. an embedded widget vs. a full page) — split the tree into two adapters with their own
defaultOptions.
Reference: nuqs 2.5 release notes
Import Server Utilities from nuqs/server
Server-side utilities like createSearchParamsCache must be imported from nuqs/server, not nuqs. The main nuqs export includes the 'use client' directive which contaminates Server Components.
Incorrect (wrong import):
// lib/searchParams.ts
import { createSearchParamsCache, parseAsString } from 'nuqs'
// Error: This import adds 'use client' to your server code
export const searchParamsCache = createSearchParamsCache({
q: parseAsString.withDefault('')
})Correct (server import):
// lib/searchParams.ts
import {
createSearchParamsCache,
parseAsString,
parseAsInteger
} from 'nuqs/server'
// No 'use client' directive - safe for Server Components
export const searchParamsCache = createSearchParamsCache({
q: parseAsString.withDefault(''),
page: parseAsInteger.withDefault(1)
})Usage in Server Component:
// app/search/page.tsx
import { searchParamsCache } from '@/lib/searchParams'
import type { SearchParams } from 'nuqs/server'
type PageProps = {
searchParams: Promise<SearchParams>
}
export default async function SearchPage({ searchParams }: PageProps) {
const { q, page } = await searchParamsCache.parse(searchParams)
return (
<div>
<h1>Results for: {q}</h1>
<p>Page: {page}</p>
</div>
)
}What to import from where:
| Import | Source | Use In |
|---|---|---|
useQueryState, useQueryStates | nuqs | Client Components |
createSearchParamsCache | nuqs/server | Server Components |
Parsers (parseAsString, etc.) | nuqs/server for server, nuqs for client | Either |
Reference: nuqs Server-Side
Ensure Compatible Next.js Version
nuqs requires specific Next.js versions depending on the router you use. Using incompatible versions causes runtime errors or missing functionality.
Version Requirements:
| Router | Minimum Next.js | Notes |
|---|---|---|
| App Router | 14.2.0+ | Full support including streaming |
| App Router (basic) | 14.0.0+ | Limited features |
| App Router (Next.js 15+) | 15.0.0+ | searchParams is Promise<SearchParams> — must be await-ed. See server-next15-async. |
| Pages Router | 12.0.0+ | Full support |
Check your version:
npm list next
# or
yarn why next
# or
pnpm why nextIncorrect (outdated Next.js):
{
"dependencies": {
"next": "13.5.0",
"nuqs": "^2.0.0"
}
}
// May cause: "Cannot read property 'push' of undefined"
// Or: URL updates not reflectedCorrect (compatible version):
{
"dependencies": {
"next": "14.2.0",
"nuqs": "^2.0.0"
}
}Upgrade command:
npm install next@latest
# or
yarn add next@latest
# or
pnpm add next@latestCommon symptoms of version mismatch:
useQueryStatereturns undefined- URL doesn't update on state change
- Hydration mismatches
TypeError: Cannot read property 'push' of undefined
Reference: nuqs Installation
Wrap App with NuqsAdapter
nuqs requires the NuqsAdapter provider to function. Without it, useQueryState hooks won't sync with the URL and may throw errors.
Incorrect (missing adapter):
// src/app/layout.tsx
export default function RootLayout({
children
}: {
children: React.ReactNode
}) {
return (
<html>
<body>{children}</body>
</html>
)
}
// useQueryState calls will fail silently or throwCorrect (App Router):
// src/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>
)
}Correct (Pages Router):
// src/pages/_app.tsx
import type { AppProps } from 'next/app'
import { NuqsAdapter } from 'nuqs/adapters/next/pages'
export default function MyApp({ Component, pageProps }: AppProps) {
return (
<NuqsAdapter>
<Component {...pageProps} />
</NuqsAdapter>
)
}Available adapters (nuqs v2.8+):
nuqs/adapters/next/app— Next.js App Routernuqs/adapters/next/pages— Next.js Pages Routernuqs/adapters/next— Next.js unified (mixed routers)nuqs/adapters/react— Plain React (no router, e.g. Vite/CRA)nuqs/adapters/remix— Remixnuqs/adapters/react-router/v5— React Router v5 (added v2.8)nuqs/adapters/react-router/v6— React Router v6nuqs/adapters/react-router/v7— React Router v7nuqs/adapters/tanstack-router— TanStack Router (added v2.5)nuqs/adapters/testing— Tests (seedebug-testing)
The unversioned nuqs/adapters/react-router (alias for v6) is deprecated and will be removed in v3 — always pin a version.
Reference: nuqs Adapters
Define Shared Parsers in Dedicated File
When multiple components use the same URL parameters, define parsers in a shared file. This prevents mismatches where one component uses parseAsInteger and another uses parseAsString for the same parameter.
Incorrect (duplicate parser definitions):
// components/Pagination.tsx
'use client'
import { useQueryState, parseAsInteger } from 'nuqs'
export function Pagination() {
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1))
return <button onClick={() => setPage(p => p + 1)}>Next</button>
}
// components/PageInfo.tsx
'use client'
import { useQueryState } from 'nuqs'
export function PageInfo() {
const [page] = useQueryState('page') // String parser - mismatch!
return <span>Page: {page}</span> // Shows "1" not 1
}Correct (shared parsers):
// lib/searchParams.ts
import { parseAsInteger, parseAsString, parseAsStringLiteral } from 'nuqs'
export const searchParams = {
page: parseAsInteger.withDefault(1),
query: parseAsString.withDefault(''),
sort: parseAsStringLiteral(['asc', 'desc'] as const).withDefault('asc')
}
// components/Pagination.tsx
'use client'
import { useQueryState } from 'nuqs'
import { searchParams } from '@/lib/searchParams'
export function Pagination() {
const [page, setPage] = useQueryState('page', searchParams.page)
return <button onClick={() => setPage(p => p + 1)}>Next</button>
}
// components/PageInfo.tsx
'use client'
import { useQueryState } from 'nuqs'
import { searchParams } from '@/lib/searchParams'
export function PageInfo() {
const [page] = useQueryState('page', searchParams.page)
return <span>Page: {page}</span> // Correctly typed as number
}Benefits:
- Single source of truth for parser configuration
- TypeScript catches mismatches at compile time
- Easy to update defaults in one place
- Shared between client and server (with proper imports)
Reference: nuqs Documentation
Add 'use client' Directive for Hooks
useQueryState and useQueryStates are React hooks that require client-side rendering. Using them in Server Components causes build errors.
Incorrect (missing directive):
// app/search/page.tsx
import { useQueryState } from 'nuqs'
export default function SearchPage() {
const [query, setQuery] = useQueryState('q')
// Error: Hooks can only be called inside Client Components
return <input value={query ?? ''} onChange={e => setQuery(e.target.value)} />
}Correct (client component):
// app/search/page.tsx
'use client'
import { useQueryState } from 'nuqs'
export default function SearchPage() {
const [query, setQuery] = useQueryState('q')
return <input value={query ?? ''} onChange={e => setQuery(e.target.value)} />
}Alternative (extract to client component):
// app/search/page.tsx (Server Component)
import SearchInput from './SearchInput'
export default function SearchPage() {
return (
<div>
<h1>Search</h1>
<SearchInput />
</div>
)
}
// app/search/SearchInput.tsx (Client Component)
'use client'
import { useQueryState } from 'nuqs'
export default function SearchInput() {
const [query, setQuery] = useQueryState('q')
return <input value={query ?? ''} onChange={e => setQuery(e.target.value)} />
}Note: For reading search params in Server Components without hooks, use createSearchParamsCache from nuqs/server.
Reference: nuqs Server-Side
Avoid Derived State from URL Parameters
Don't copy URL state into local useState. This creates two sources of truth that can drift out of sync. Use the URL state directly or compute derived values.
Incorrect (duplicated state):
'use client'
import { useState, useEffect } from 'react'
import { useQueryState, parseAsInteger } from 'nuqs'
export default function Pagination() {
const [urlPage] = useQueryState('page', parseAsInteger.withDefault(1))
const [page, setPage] = useState(urlPage) // Duplicated!
useEffect(() => {
setPage(urlPage) // Sync attempt - can cause loops
}, [urlPage])
return <span>Page: {page}</span>
}Correct (single source of truth):
'use client'
import { useQueryState, parseAsInteger } from 'nuqs'
export default function Pagination() {
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1))
return <span>Page: {page}</span>
}For derived values, use useMemo:
'use client'
import { useMemo } from 'react'
import { useQueryState, parseAsInteger } from 'nuqs'
export default function Pagination() {
const [page] = useQueryState('page', parseAsInteger.withDefault(1))
// Derived value, not duplicated state
const isFirstPage = useMemo(() => page === 1, [page])
const pageRange = useMemo(
() => ({ start: (page - 1) * 10, end: page * 10 }),
[page]
)
return (
<div>
<span>Page: {page}</span>
{isFirstPage && <span>(First page)</span>}
</div>
)
}Exception: Debounced input
// OK to have local state for debounced input
const [query, setQuery] = useQueryState('q')
const [inputValue, setInputValue] = useState(query ?? '')
// Debounce URL updates
useEffect(() => {
const timeout = setTimeout(() => setQuery(inputValue || null), 300)
return () => clearTimeout(timeout)
}, [inputValue, setQuery])Reference: React Derived State
Clear URL Parameters with null
To remove a parameter from the URL, set it to null. Setting to empty string ('') or 0 keeps the parameter in the URL with that value.
Incorrect (empty string in URL):
'use client'
import { useQueryState } from 'nuqs'
export default function SearchBox() {
const [query, setQuery] = useQueryState('q')
const clear = () => setQuery('')
// URL: ?q= (empty but parameter remains)
return (
<div>
<input value={query ?? ''} onChange={e => setQuery(e.target.value)} />
<button onClick={clear}>Clear</button>
</div>
)
}Correct (null removes parameter):
'use client'
import { useQueryState } from 'nuqs'
export default function SearchBox() {
const [query, setQuery] = useQueryState('q')
const clear = () => setQuery(null)
// URL: / (parameter removed entirely)
return (
<div>
<input
value={query ?? ''}
onChange={e => setQuery(e.target.value || null)}
/>
<button onClick={clear}>Clear</button>
</div>
)
}Pattern: Convert empty to null on change:
<input
value={query ?? ''}
onChange={e => setQuery(e.target.value || null)}
/>
// Empty input → null → clean URL
// "search term" → "search term" → ?q=search+termWith typed parsers:
const [count, setCount] = useQueryState('count', parseAsInteger)
// Clear the parameter
setCount(null) // URL: /
// With default, null resets to default behavior
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1))
setPage(null) // URL: / (page defaults to 1, not shown)Reference: nuqs Documentation
Handle Controlled Input Value Properly
React requires controlled inputs to always have a defined value. Since nuqs returns null when a parameter is absent, you must provide a fallback for input elements.
Incorrect (uncontrolled warning):
'use client'
import { useQueryState } from 'nuqs'
export default function SearchBox() {
const [query, setQuery] = useQueryState('q')
// query is string | null
return (
<input
value={query} // Warning: value is null initially
onChange={e => setQuery(e.target.value)}
/>
)
}
// Warning: A component is changing an uncontrolled input to be controlledCorrect (fallback value):
'use client'
import { useQueryState } from 'nuqs'
export default function SearchBox() {
const [query, setQuery] = useQueryState('q')
return (
<input
value={query ?? ''} // Fallback to empty string
onChange={e => setQuery(e.target.value || null)}
/>
)
}Alternative (withDefault):
'use client'
import { useQueryState, parseAsString } from 'nuqs'
export default function SearchBox() {
const [query, setQuery] = useQueryState('q', parseAsString.withDefault(''))
// query is string (never null)
return (
<input
value={query} // No fallback needed
onChange={e => setQuery(e.target.value)}
/>
)
}For select elements:
const [sort, setSort] = useQueryState(
'sort',
parseAsStringLiteral(['asc', 'desc'] as const).withDefault('asc')
)
<select value={sort} onChange={e => setSort(e.target.value as typeof sort)}>
<option value="asc">Ascending</option>
<option value="desc">Descending</option>
</select>Reference: React Controlled Components
Use Functional Updates for State Derived from Previous Value
When the new state depends on the previous state (incrementing, toggling, appending), use functional updates. Direct state references in closures can be stale.
Incorrect (stale closure):
'use client'
import { useQueryState, parseAsInteger } from 'nuqs'
export default function Counter() {
const [count, setCount] = useQueryState('count', parseAsInteger.withDefault(0))
const incrementTwice = () => {
setCount(count + 1) // Uses stale `count`
setCount(count + 1) // Still uses same stale `count`
// Result: increments by 1, not 2
}
return <button onClick={incrementTwice}>+2</button>
}Correct (functional update):
'use client'
import { useQueryState, parseAsInteger } from 'nuqs'
export default function Counter() {
const [count, setCount] = useQueryState('count', parseAsInteger.withDefault(0))
const incrementTwice = () => {
setCount(c => c + 1) // Gets latest value
setCount(c => c + 1) // Gets updated value
// Result: correctly increments by 2
}
return <button onClick={incrementTwice}>+2</button>
}More examples:
// Toggle boolean
const [enabled, setEnabled] = useQueryState('enabled', parseAsBoolean.withDefault(false))
setEnabled(e => !e)
// Append to array
const [tags, setTags] = useQueryState(
'tags',
parseAsArrayOf(parseAsString).withDefault([])
)
setTags(t => [...t, 'new-tag'])
// Remove from array
setTags(t => t.filter(tag => tag !== 'remove-me'))Reference: nuqs State Updates
Use withOptions for Parser-Level Configuration
Instead of passing options to every useQueryState call, configure options on the parser itself with withOptions. This ensures consistent behavior and reduces repetition.
Incorrect (repeated options):
'use client'
import { useQueryState, parseAsString } from 'nuqs'
export default function SearchPage() {
const [query, setQuery] = useQueryState('q', {
...parseAsString,
shallow: false,
throttleMs: 500,
history: 'push'
})
const [filter, setFilter] = useQueryState('filter', {
...parseAsString,
shallow: false,
throttleMs: 500,
history: 'push'
})
// Repeated configuration for each parameter
}Correct (parser-level options):
// lib/searchParams.ts
import { parseAsString, parseAsInteger } from 'nuqs'
const serverSyncOptions = {
shallow: false,
throttleMs: 500,
history: 'push' as const
}
export const searchParams = {
query: parseAsString.withDefault('').withOptions(serverSyncOptions),
filter: parseAsString.withDefault('').withOptions(serverSyncOptions),
page: parseAsInteger.withDefault(1).withOptions(serverSyncOptions)
}
// components/SearchPage.tsx
'use client'
import { useQueryState } from 'nuqs'
import { searchParams } from '@/lib/searchParams'
export default function SearchPage() {
const [query, setQuery] = useQueryState('q', searchParams.query)
const [filter, setFilter] = useQueryState('filter', searchParams.filter)
const [page, setPage] = useQueryState('page', searchParams.page)
// All use the same options consistently
}Options can be chained:
parseAsInteger
.withDefault(1)
.withOptions({ shallow: false })
.withOptions({ throttleMs: 300 }) // Merges with previous optionsReference: nuqs Options
Use Setter Return Value for URL Access
The state setter returns a Promise<URLSearchParams> that resolves to the merged URL search params after the update is flushed. Use it whenever you need the resulting URL immediately (sharing, copy-to-clipboard, analytics) instead of re-deriving the URL by hand and risking drift from nuqs's own serialisation. Because it resolves to a URLSearchParams object, you must call .toString() when embedding it in a string.
Incorrect (manually reconstructing the URL):
'use client'
import { useQueryState, parseAsString } from 'nuqs'
export default function ShareButton() {
const [query, setQuery] = useQueryState('q', parseAsString.withDefault(''))
const share = () => {
setQuery('shared-term')
// Manual URL construction — drifts from nuqs's encoding (clearOnDefault, urlKeys, etc.)
const url = `${window.location.pathname}?q=shared-term`
navigator.clipboard.writeText(url)
}
return <button onClick={share}>Share</button>
}Correct (use the awaited return value):
'use client'
import { useQueryState, parseAsString } from 'nuqs'
export default function ShareButton() {
const [query, setQuery] = useQueryState('q', parseAsString.withDefault(''))
const share = async () => {
const search = await setQuery('shared-term')
// search is URLSearchParams — call .toString() to embed in a URL.
const url = `${window.location.origin}${window.location.pathname}?${search.toString()}`
await navigator.clipboard.writeText(url)
}
return <button onClick={share}>Share</button>
}For analytics:
const trackSearch = async (term: string) => {
const search = await setQuery(term)
analytics.track('search', {
term,
url: `?${search.toString()}`,
// You can also pull individual keys directly off URLSearchParams:
canonicalQuery: search.get('q')
})
}With `useQueryStates` — merged params come back in one object:
const [coords, setCoords] = useQueryStates({
lat: parseAsFloat,
lng: parseAsFloat
})
const shareLocation = async () => {
const search = await setCoords({ lat: 48.8566, lng: 2.3522 })
// search.toString(): "lat=48.8566&lng=2.3522"
}When NOT to use this pattern:
- You only need the local in-memory value — read it from the returned state, not the setter Promise.
- You are inside Server Components — use
createSerializerinstead (seeperf-serialize-utility).
Reference: nuqs Batching
Use Standard Schema for Cross-Library Validation
Since nuqs v2.5, every parseAsX builder implements the Standard Schema interface, and parseAsJson accepts any Standard Schema validator (Zod, Valibot, ArkType, Effect Schema, …) directly in the validator slot. That means one parser map can drive all of: client-side useQueryState, server-side createSearchParamsCache/createLoader, tRPC procedure inputs, TanStack Router search-param validation, and form-level validation — without redefining the schema in three places.
Incorrect (shape defined in three places, drift inevitable):
// lib/searchParams.ts
import { parseAsString, parseAsInteger } from 'nuqs'
export const searchParams = {
q: parseAsString.withDefault(''),
page: parseAsInteger.withDefault(1)
}
// server/trpc/search.ts
import { z } from 'zod'
export const searchInput = z.object({
q: z.string().default(''),
page: z.number().int().default(1) // Default '1' here, '0' somewhere else — bug waiting to happen
})
// server/route-validator.ts
export function validateSearch(v: unknown) {
// Hand-rolled — drifts from both of the above
}Correct (one nuqs parser map, consumed everywhere via Standard Schema):
// lib/searchParams.ts
import { parseAsString, parseAsInteger, createStandardSchemaV1 } from 'nuqs'
export const searchParamsMap = {
q: parseAsString.withDefault(''),
page: parseAsInteger.withDefault(1)
}
// Expose the same map as a Standard Schema for any v1-compatible consumer
export const searchParamsSchema = createStandardSchemaV1(searchParamsMap)// server/trpc/search.ts — tRPC v11 accepts Standard Schemas
import { publicProcedure } from '@/server/trpc'
import { searchParamsSchema } from '@/lib/searchParams'
export const search = publicProcedure
.input(searchParamsSchema)
.query(({ input }) => {
// input is { q: string; page: number }
return runSearch(input.q, input.page)
})// app/search/page.tsx (client) — same map
'use client'
import { useQueryStates } from 'nuqs'
import { searchParamsMap } from '@/lib/searchParams'
export default function Filters() {
const [{ q, page }, setSearch] = useQueryStates(searchParamsMap)
return <SearchUI q={q} page={page} onChange={setSearch} />
}Using a Standard Schema library inside `parseAsJson`:
parseAsJson accepts any Standard Schema validator directly — Zod 4+, Valibot 0.30+, ArkType, and Effect Schema all qualify.
import { z } from 'zod'
import { parseAsJson, useQueryState } from 'nuqs'
const FiltersSchema = z.object({
minPrice: z.number(),
categories: z.array(z.string())
})
const [filters] = useQueryState(
'filters',
parseAsJson(FiltersSchema).withDefault({ minPrice: 0, categories: [] })
)
// Invalid JSON → null → falls back to the defaultWhen NOT to use this pattern:
- The shape only lives in one file and one consumer — keep it simple with plain parsers; the Standard Schema indirection adds zero value.
- You're on nuqs < 2.5 —
createStandardSchemaV1doesn't exist there. Either upgrade or write the bridge by hand.
Reference: nuqs 2.5 release notes
Use useQueryStates for Related Parameters
When multiple URL parameters are logically related (coordinates, date ranges, filter sets), prefer useQueryStates over a tower of useQueryState calls. nuqs already batches sibling setter calls within the same event-loop tick into a single URL flush, so the win isn't "fewer history entries" — it's a single typed state object, a single combined update payload, and a single returned URLSearchParams you can inspect for the merged result.
Incorrect (one hook per parameter):
'use client'
import { useQueryState, parseAsFloat, parseAsInteger } from 'nuqs'
export default function MapView() {
const [lat, setLat] = useQueryState('lat', parseAsFloat.withDefault(0))
const [lng, setLng] = useQueryState('lng', parseAsFloat.withDefault(0))
const [zoom, setZoom] = useQueryState('zoom', parseAsInteger.withDefault(10))
const goToParis = () => {
setLat(48.8566)
setLng(2.3522)
setZoom(12)
// Three setters, three places to keep in sync.
// Each setter resolves to its own URLSearchParams Promise — no single object holding the merged result.
}
return <button onClick={goToParis}>Go to Paris</button>
}Correct (one hook, one atomic update):
'use client'
import { useQueryStates, parseAsFloat, parseAsInteger } from 'nuqs'
export default function MapView() {
const [coords, setCoords] = useQueryStates({
lat: parseAsFloat.withDefault(0),
lng: parseAsFloat.withDefault(0),
zoom: parseAsInteger.withDefault(10)
})
const goToParis = async () => {
const search = await setCoords({ lat: 48.8566, lng: 2.3522, zoom: 12 })
// search: URLSearchParams with all three keys merged
}
return (
<div>
<p>Location: {coords.lat}, {coords.lng} (zoom: {coords.zoom})</p>
<button onClick={goToParis}>Go to Paris</button>
</div>
)
}Partial updates and clearing:
setCoords({ zoom: 15 }) // Update only zoom; lat/lng untouched
setCoords({ lat: 51.5074, lng: -0.1278 }) // Update lat/lng; keep zoom
setCoords(null) // Clear every key in the objectWhen NOT to use this pattern:
- Parameters belong to unrelated UI surfaces (e.g., a sidebar filter vs. an unrelated pagination control) — coupling them in one hook causes unnecessary re-renders of components that only need one key.
- Non-Next.js adapters (v2.5+) automatically scope re-renders to specific keys when you use
useQueryState, so splitting into independent hooks can actually be faster for re-render-sensitive trees. Seeperf-avoid-rerender.
Reference: nuqs Batching
Related skills
How it compares
Pick nuqs over generic React state skills when URL search parameters must stay type-safe, shareable, and synchronized with Next.js or multi-router React setups.
FAQ
What does nuqs do?
nuqs (type-safe URL query state) best practices for Next.js and other React frameworks. This skill should be used when writing, reviewing, or refactoring code that uses nuqs for URL state management. Triggers on tasks in
When should I use nuqs?
nuqs (type-safe URL query state) best practices for Next.js and other React frameworks. This skill should be used when writing, reviewing, or refactoring code that uses nuqs for URL state management. Triggers on tasks in
What are common prerequisites?
--- name: nuqs description: nuqs (type-safe URL query state) best practices for Next.js and other React frameworks.
Is Nuqs safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.