mcpField
N
nuqs

nuqs

github.com/noklip-ioDevelopment150 installsUpdated 39w ago
Repository

About nuqs

A best-practices skill for nuqs, which manages type-safe React state in URL query parameters. It covers parsers, updates, server access, framework adapters, and testing patterns.

Key features

  1. 01Typed query-state parsers
  2. 02Single and multiple parameter hooks
  3. 03URL update and history options
  4. 04Server-side search parameter parsing
  5. 05Testing adapter guidance

Use cases

  1. 01Keeping search and filter state in URLs
  2. 02Building typed pagination and sorting parameters
  3. 03Reading query parameters in Next.js server components
  4. 04Testing URL state changes

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/noklip-io/agent-skills --skill nuqs

After installing, type/nuqsin Claude Code to confirm it loaded.

Bundled filesREADME.md

Details

From the project README

nuqs Best Practices

Type-safe URL query state management for React. Like useState, but stored in the URL.

Setup (Required First)

Wrap your app with the appropriate adapter:

// Next.js App Router - app/layout.tsx
import { NuqsAdapter } from 'nuqs/adapters/next/app'

export default function RootLayout({ children }) {
  return <NuqsAdapter>{children}</NuqsAdapter>
}

// Next.js Pages Router - pages/_app.tsx
import { NuqsAdapter } from 'nuqs/adapters/next/pages'

// React SPA (Vite/CRA)
import { NuqsAdapter } from 'nuqs/adapters/react'

// Remix - app/root.tsx
import { NuqsAdapter } from 'nuqs/adapters/remix'

// React Router v6/v7
import { NuqsAdapter } from 'nuqs/adapters/react-router'
Global Options
import { throttle } from 'nuqs'

<NuqsAdapter
  defaultOptions={{
    shallow: false,        // notify server by default
    scroll: true,          // scroll to top on change
    clearOnDefault: true,  // remove param when equals default
    limitUrlUpdates: throttle(250)  // throttle URL updates
  }}
>
  {children}
</NuqsAdapter>

Core API

Single Parameter
'use client'
import { useQueryState, parseAsInteger } from 'nuqs'

// String (default) - returns null | string
const [search, setSearch] = useQueryState('q')

// With parser + default (recommended)
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1))

// Updates
setSearch('hello')           // ?q=hello
setSearch(null)              // removes param
setPage(p => p + 1)          // functional update
await setPage(5)             // returns Promise<URLSearchParams>
Multiple Parameters
import { useQueryStates, parseAsInteger, parseAsString } from 'nuqs'

const [filters, setFilters] = useQueryStates({
  q: parseAsString.withDefault(''),
  page: parseAsInteger.withDefault(1),
  sort: parseAsString.withDefault('date')
})

// Partial updates
setFilters({ page: 1, sort: 'name' })

// Await batch update
const params = await setFilters({ page: 2 })
params.get('page')  // '2'

Built-in Parsers

ParserTypeExample URL
parseAsStringstring?q=hello
parseAsIntegernumber?page=1
parseAsFloatnumber?price=9.99
parseAsHexnumber?color=ff0000
parseAsBooleanboolean?active=true
parseAsIsoDateTimeDate?date=2024-01-15T10:30:00Z
parseAsTimestampDate?t=1705312200000
parseAsArrayOf(parser)T[]?tags=a,b,c
parseAsArrayOf(parser, ';')T[]?ids=1;2;3 (custom separator)
parseAsJson<T>()T?data={"key":"value"}
parseAsStringEnum(values)enum?status=active
parseAsStringLiteral(arr)literal?sort=asc
parseAsNumberLiteral(arr)literal?dice=6
Enum & Literal Examples
// String enum
enum Status { Active = 'active', Inactive = 'inactive' }
const [status] = useQueryState('status',
  parseAsStringEnum(Object.values(Status)).withDefault(Status.Active)
)

// String literal (type-safe)
const sortOptions = ['asc', 'desc'] as const
const [sort] = useQueryState('sort',
  parseAsStringLiteral(sortOptions).withDefault('asc')
)

// Number literal
const diceSides = [1, 2, 3, 4, 5, 6] as const
const [dice] = useQueryState('dice',
  parseAsNumberLiteral(diceSides).withDefault(1)
)
Arrays
// Default comma separator: ?tags=react,typescript,nuqs
const [tags, setTags] = useQueryState('tags',
  parseAsArrayOf(parseAsString).withDefault([])
)

// Custom separator: ?ids=1;2;3
const [ids] = useQueryState('ids',
  parseAsArrayOf(parseAsInteger, ';').withDefault([])
)

Options

useQueryState('key', parseAsString.withOptions({
  history: 'push',       // 'push' | 'replace' (default)
  shallow: false,        // true (default) = client only, false = notify server
  scroll: false,         // scroll to top on change
  throttleMs: 500,       // throttle URL updates (min 50ms)
  clearOnDefault: true,  // remove param when equals default (default: true)
  startTransition,       // React useTransition for loading states
}))

Options precedence: call-level > parser-level > hook-level > global adapter

// Parser-level options
const parser = parseAsString.withOptions({ shallow: false })

// Hook-level options
const [q, setQ] = useQueryState('q', parser, { history: 'push' })

// Call-level override (highest priority)
setQ('value', { shallow: true })

Functional Updates & Batching

// Functional updates
setCount(c => c + 1)
setCount(c => c * 2)  // Both batched in same tick

// Chained functional updates execute in order
function onClick() {
  setCount(x => x + 1)  // 0 → 1
  setCount(x => x * 2)  // 1 → 2
}

// Await updates
const search = await setFilters({ page: 2 })
search.get('page')  // '2'

Loading States with useTransition

'use client'
import { useTransition } from 'react'
import { useQueryState, parseAsString } from 'nuqs'

function Search({ results }) {
  const [isLoading, startTransition] = useTransition()

  const [query, setQuery] = useQueryState('q',
    parseAsString.withOptions({
      startTransition,  // enables loading state
      shallow: false    // required for server updates
    })
  )

  return (
    <>
      <input value={query ?? ''} onChange={e => setQuery(e.target.value)} />
      {isLoading ? <Spinner /> : <Results data={results} />}
    </>
  )
}

Custom Parsers

Basic Custom Parser
// Simple date parser
const parseAsDate = {
  parse: (value: string) => new Date(value),
  serialize: (date: Date) => date.toISOString().split('T')[0]
}

const [date, setDate] = useQueryState('date', parseAsDate)
With createParser (for reference types)

For non-primitive types, provide eq function for clearOnDefault to work:

import { createParser, parseAsStringLiteral } from 'nuqs'

// Date with equality check
const parseAsDate = createParser({
  parse: (value: string) => new Date(value.slice(0, 10)),
  serialize: (date: Date) => date.toISOString().slice(0, 10),
  eq: (a: Date, b: Date) => a.getTime() === b.getTime()
})

// Complex type (e.g., TanStack Table sort state)
// URL: ?sort=name:asc → { id: 'name', desc: false }
const parseAsSort = createParser({
  parse(query) {
    const [id = '', dir = ''] = query.split(':')
    return { id, desc: dir === 'desc' }
  },
  serialize(value) {
    return `${value.id}:${value.desc ? 'desc' : 'asc'}`
  },
  eq(a, b) {
    return a.id === b.id && a.desc === b.desc
  }
})

Server Components (Next.js)

// lib/searchParams.ts
import { createSearchParamsCache, parseAsInteger, parseAsString } from 'nuqs/server'

export const searchParamsCache = createSearchParamsCache({
  q: parseAsString.withDefault(''),
  page: parseAsInteger.withDefault(1)
})

// app/search/page.tsx (Server Component)
import { searchParamsCache } from '@/lib/searchParams'
import type { SearchParams } from 'nuqs/server'

type Props = { searchParams: Promise<SearchParams> }

export default async function Page({ searchParams }: Props) {
  // ⚠️ Must call parse() - don't forget!
  const { q, page } = await searchParamsCache.parse(searchParams)
  return <Results query={q} page={page} />
}

// Nested server component - no props needed
function NestedComponent() {
  const page = searchParamsCache.get('page')  // type-safe!
  return <span>Page {page}</span>
}

Reusable Patterns

Shared Parser Definitions
// lib/parsers.ts
export const paginationParsers = {
  page: parseAsInteger.withDefault(1),
  limit: parseAsInteger.withDefault(20),
  sort: parseAsString.withDefault('createdAt'),
  order: parseAsStringLiteral(['asc', 'desc'] as const).withDefault('desc')
}

// Component
const [pagination, setPagination] = useQueryStates(paginationParsers)
URL Key Mapping
const [coords, setCoords] = useQueryStates(
  {
    latitude: parseAsFloat.withDefault(0),
    longitude: parseAsFloat.withDefault(0)
  },
  {
    urlKeys: { latitude: 'lat', longitude: 'lng' }
  }
)
// URL: ?lat=45.5&lng=-122.6
// Code: coords.latitude, coords.longitude
Custom Hook
// hooks/useFilters.ts
export function useFilters() {
  return useQueryStates({
    search: parseAsString.withDefault(''),
    category: parseAsString,
    minPrice: parseAsFloat,
    maxPrice: parseAsFloat,
    inStock: parseAsBoolean.withDefault(false)
  })
}

// Component
const [filters, setFilters] = useFilters()

Testing

import { withNuqsTestingAdapter, type UrlUpdateEvent } from 'nuqs/adapters/testing'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'

it('updates URL on click', async () => {
  const user = userEvent.setup()
  const onUrlUpdate = vi.fn<[UrlUpdateEvent]>()

  render(<CounterButton />, {
    wrapper: withNuqsTestingAdapter({
      searchParams: '?count=1',
      onUrlUpdate
    })
  })

  await user.click(screen.getByRole('button'))

  expect(screen.getByRole('button')).toHaveTextContent('count is 2')
  expect(onUrlUpdate).toHaveBeenCalledOnce()

  const event = onUrlUpdate.mock.calls[0]![0]!
  expect(event.queryString).toBe('?count=2')
  expect(event.searchParams.get('count')).toBe('2')
  expect(event.options.history).toBe('push')
})

Critical Mistakes to Avoid

1. Missing Adapter
// ❌ Error: nuqs requires an adapter
useQueryState('q')

// ✅ Wrap app in NuqsAdapter first (see Setup section)
2. Wrong Adapter for Framework
// ❌ Using app router adapter in pages router
import { NuqsAdapter } from 'nuqs/adapters/next/app'  // Wrong!

// ✅ Match adapter to your router
import { NuqsAdapter } from 'nuqs/adapters/next/pages'
3. Missing Suspense (Next.js App Router)
// ❌ Hydration error
export default function Page() {
  const [q] = useQueryState('q')
  return <div>{q}</div>
}

// ✅ Wrap client components in Suspense
export default function Page() {
  return (
    <Suspense fallback={<Loading />}>
      <SearchClient />
    </Suspense>
  )
}
4. Same Key, Different Parsers
// ❌ Conflicts - last update wins with wrong type
const [intVal] = useQueryState('foo', parseAsInteger)
const [floatVal] = useQueryState('foo', parseAsFloat)

// ✅ One parser per key, share via custom hook
function useFoo() {
  const [val, setVal] = useQueryState('foo', parseAsFloat)
  return { float: val, int: Math.floor(val ?? 0), setVal }
}
5. Forgetting to Parse on Server
// ❌ Returns cache object, not values
const values = searchParamsCache  // Wrong!

// ✅ Call parse() with searchParams prop
const values = await searchParamsCache.parse(searchParams)
6. Server Component with Client Hook
// ❌ useQueryState only works in client components
export default function Page() {  // Server component
  const [q] = useQueryState('q')  // Error!
}

// ✅ Use createSearchParamsCache for server, useQueryState for client
7. Not Handling Null Without Default
// ❌ Tedious null handling
const [count, setCount] = useQueryState('count', parseAsInteger)
setCount(c => (c ?? 0) + 1)  // Must handle null every time

// ✅ Use withDefault
const [count, setCount] = useQueryState('count', parseAsInteger.withDefault(0))
setCount(c => c + 1)  // Always a number
8. Lossy Serialization
// ❌ Loses precision on reload
const geoParser = {
  parse: parseFloat,
  serialize: v => v.toFixed(2)  // 1.23456 → "1.23" → 1.23
}

// ✅ Preserve precision or accept the tradeoff knowingly
const geoParser = {
  parse: parseFloat,
  serialize: v => v.toString()
}
9. Missing eq for Reference Types
// ❌ clearOnDefault won't work correctly
const dateParser = {
  parse: (v) => new Date(v),
  serialize: (d) => d.toISOString()
}

// ✅ Provide eq function for reference types
const dateParser = createParser({
  parse: (v) => new Date(v),
  serialize: (d) => d.toISOString(),
  eq: (a, b) => a.getTime() === b.getTime()
})

Quick Reference

TaskSolution
Single paramuseQueryState('key', parser.withDefault(val))
Multiple paramsuseQueryStates({ key: parser })
Server accesscreateSearchParamsCache + .parse()
Notify server{ shallow: false }
History entry{ history: 'push' }
Loading stateuseTransition + { startTransition }
Short URL keysurlKeys: { longName: 'short' }
Array paramparseAsArrayOf(parser) or parseAsArrayOf(parser, ';')
Enum/literalparseAsStringLiteral(['a', 'b'] as const)
Custom typecreateParser({ parse, serialize, eq })
Test componentwithNuqsTestingAdapter({ searchParams: '?...' })

Comments

Markdown supported