DEV Community

nuqs: URL State Management in Next.js (2026)

Every data table, search page, and filtered list has the same problem: the user applies a filter, the results update, they share the URL - and the recipient lands on an empty page because the filter lived in useState and died with the component.

nuqs solves this with a useState-compatible API where the URL is the state container.

What nuqs Does

// Regular state - lost on reload, not shareable
const [search, setSearch] = useState('')

// URL state - survives reload, shareable, bookmarkable
const [search, setSearch] = useQueryState('q')

Type a few characters and the URL becomes ?q=keyboard. Refresh - the input still has its value. Copy the URL - someone else sees the same filtered results.

Setup

npm install nuqs
// app/layout.tsx
import { NuqsAdapter } from 'nuqs/adapters/next/app'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <NuqsAdapter>{children}</NuqsAdapter>
      </body>
    </html>
  )
}

useQueryState: Single Parameter

'use client'

import { useQueryState } from 'nuqs'

export function SearchInput() {
  const [query, setQuery] = useQueryState('q')

  return (
    <input
      value={query ?? ''}
      onChange={(e) => setQuery(e.target.value || null)}
      placeholder="Search..."
    />
  )
}

Setting the value to null removes the parameter from the URL entirely - the equivalent of "clear this filter."

Parsers: Type Safety Beyond Strings

import {
  parseAsInteger,
  parseAsFloat,
  parseAsBoolean,
  parseAsArrayOf,
  parseAsString,
  parseAsStringEnum,
  parseAsIsoDate,
} from 'nuqs'

// Page number - URL: ?page=3
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1))

// Multi-select - URL: ?tags=react,typescript
const [tags, setTags] = useQueryState(
  'tags',
  parseAsArrayOf(parseAsString).withDefault([])
)

// Enum - URL: ?sort=price_asc
const [sort, setSort] = useQueryState(
  'sort',
  parseAsStringEnum(['name_asc', 'name_desc', 'price_asc', 'price_desc'] as const)
    .withDefault('name_asc')
)

// Date - URL: ?from=2026-01-01
const [from, setFrom] = useQueryState('from', parseAsIsoDate)

.withDefault(value) means the hook returns the default when the parameter is absent, instead of null.

useQueryStates: Multiple Parameters Together

When parameters belong together, update them atomically:

import { useQueryStates, parseAsInteger, parseAsString, parseAsStringEnum } from 'nuqs'

export function useTableState() {
  return useQueryStates({
    page: parseAsInteger.withDefault(1),
    perPage: parseAsInteger.withDefault(20),
    q: parseAsString.withDefault(''),
    sortBy: parseAsStringEnum(['name', 'email', 'createdAt'] as const)
      .withDefault('createdAt'),
    sortDir: parseAsStringEnum(['asc', 'desc'] as const)
      .withDefault('desc'),
  })
}

function UsersTable() {
  const [{ page, perPage, q, sortBy, sortDir }, setTableState] = useTableState()

  function handleSort(field: 'name' | 'email' | 'createdAt') {
    setTableState({
      sortBy: field,
      sortDir: sortBy === field && sortDir === 'asc' ? 'desc' : 'asc',
      page: 1, // reset page on sort change
    })
  }
}

setTableState accepts a partial object - only specified keys update.

Navigation Options

// Trigger full server re-render (re-fetches Server Components)
setValue(value, { shallow: false })

// Push to history (back button returns to previous filter state)
setValue(value, { history: 'push' })

// Throttle URL writes (useful for live search inputs)
setValue(value, { throttleMs: 200 })

shallow: false is critical for App Router: it makes the URL update trigger a real navigation so Server Components that read searchParams get the new values.

Server Component Integration

// lib/search-params.ts
import { createSearchParamsCache, parseAsInteger, parseAsString } from 'nuqs/server'

export const searchParamsCache = createSearchParamsCache({
  page: parseAsInteger.withDefault(1),
  perPage: parseAsInteger.withDefault(20),
  q: parseAsString.withDefault(''),
  status: parseAsStringEnum(['active', 'inactive', 'all'] as const)
    .withDefault('all'),
})
// app/users/page.tsx (Server Component)
import { searchParamsCache } from '@/lib/search-params'

export default async function UsersPage({ searchParams }) {
  const { page, perPage, q, status } = searchParamsCache.parse(await searchParams)

  const users = await db.query.users.findMany({
    where: and(
      q ? ilike(users.name, `%${q}%`) : undefined,
      status !== 'all' ? eq(users.status, status) : undefined,
    ),
    offset: (page - 1) * perPage,
    limit: perPage,
  })

  return <UsersTable initialUsers={users} />
}

The same parsers serve both server (DB query) and client (filter controls).

Real Pattern: Filters with startTransition

'use client'

export function UserFilters() {
  const [isPending, startTransition] = useTransition()
  const [filters, setFilters] = useQueryStates({
    q: parseAsString.withDefault(''),
    status: parseAsStringEnum(['all', 'active', 'inactive'] as const)
      .withDefault('all'),
    page: parseAsInteger.withDefault(1),
  })

  function updateFilter(key: keyof typeof filters, value: any) {
    startTransition(() => {
      setFilters({ [key]: value, page: 1 }, { shallow: false })
    })
  }

  return (
    <div className={cn('flex gap-3', isPending && 'opacity-60 pointer-events-none')}>
      <Input
        value={filters.q}
        onChange={(e) => updateFilter('q', e.target.value || '')}
        placeholder="Search..."
      />
      <Select value={filters.status} onValueChange={(v) => updateFilter('status', v)}>
        <SelectTrigger className="w-36">
          <SelectValue />
        </SelectTrigger>
        <SelectContent>
          <SelectItem value="all">All</SelectItem>
          <SelectItem value="active">Active</SelectItem>
          <SelectItem value="inactive">Inactive</SelectItem>
        </SelectContent>
      </Select>
      {(filters.q || filters.status !== 'all') && (
        <Button
          variant="ghost"
          size="sm"
          onClick={() =>
            setFilters({ q: '', status: 'all', page: 1 }, { shallow: false })
          }
        >
          Clear
        </Button>
      )}
    </div>
  )
}

Quick Reference

// Setup
<NuqsAdapter>{children}</NuqsAdapter>

// Single param
const [value, setValue] = useQueryState('key', parser.withDefault(default))

// Multiple params (atomic)
const [state, setState] = useQueryStates({ key1: parser1, key2: parser2 })

// Common parsers
parseAsString | parseAsInteger | parseAsFloat | parseAsBoolean
parseAsArrayOf(p) | parseAsStringEnum([]) | parseAsIsoDate | parseAsJson(schema)

// Options
setValue(v, { shallow: false })   // trigger server re-render
setValue(v, { history: 'push' })  // add to history
setValue(v, { throttleMs: 300 })  // debounce

// Server
import { createSearchParamsCache } from 'nuqs/server'
const cache = createSearchParamsCache({ page: parseAsInteger.withDefault(1) })
const { page } = cache.parse(await searchParams)

Full article at stacknotice.com/blog/nuqs-nextjs-url-state-2026

Comments

No comments yet. Start the discussion.