Skip to content

feat: unified API#1350

Draft
franky47 wants to merge 18 commits intonextfrom
feat/unified-api
Draft

feat: unified API#1350
franky47 wants to merge 18 commits intonextfrom
feat/unified-api

Conversation

@franky47
Copy link
Copy Markdown
Member

@franky47 franky47 commented Mar 5, 2026

Tasks

  • Add docs
  • Document type-level breaking changes (inline parsers)
  • Story for type-safe links with typedRoutes in Next.js & href() in RRv7

TL;DR

defineSearchParams is a new top-level function that creates a single, reusable, composable, "unified" definition of your search params. From this one definition you get the following features:

  • single argument in useQueryStates
  • loader
  • serializer
  • Standard Schema v1 validator
  • type inference

All sharing the same parser definitions and options.

Motivation

Today, using nuqs across the full stack requires assembling several separate primitives:

// Current API — multiple exports for the same search params definition
const parsers = {
  search: parseAsString.withDefault(''),
  page: parseAsInteger.withDefault(1)
}
export const urlKeys: UrlKeys<typeof parsers> = {
  search: 'q'
}

export const loader = createLoader(parsers, { urlKeys })
export const serializer = createSerializer(parsers, {
  urlKeys
})
export const schema = createStandardSchemaV1(parsers, {
  urlKeys
})

export const useSearch = () => useQueryStates(parsers, { urlKeys })

Options like urlKeys, clearOnDefault etc must be repeated at every call site. This is error-prone, and hard to keep in sync. Also it requires naming multiple exported symbols (cognitive overhead).

API

defineSearchParams(parsers, options?)

import { defineSearchParams, parseAsString, parseAsInteger } from 'nuqs'
// also available from 'nuqs/server'

const searchParams = defineSearchParams(
  {
    search: parseAsString.withDefault(''),
    page: parseAsInteger.withDefault(1)
  },
  {
    // All options are optional
    urlKeys: {
      // keys here are type-safe
      search: 'q'
    },
    shallow: false
  }
)

What you get back

The returned searchParams object exposes the following:

Property / Method Description
.load(anything) Universal loader (same as createLoader)
.serialize(values) Serializer (same as createSerializer)
['~standard'] Standard Schema v1 interface for use with tRPC, TanStack Router, etc.
.parsers The raw parser map, for direct access
.options The options passed to defineSearchParams
.extend(parsers, options?) Compose with more parsers (see below)
.pick({ key: true }) Create a subset (see below)
.$infer Type-level helper (see Type Inference below)

Using with useQueryStates

Pass the unified object directly as the first argument:

function SearchPage() {
  const [state, setState] = useQueryStates(searchParams)
  //     ^? { search: string, page: number }

  return (
    <input
      value={state.search}
      onChange={e => setState({ search: e.target.value })}
    />
  )
}

All options from defineSearchParams (urlKeys, history, shallow, scroll, etc.) are automatically forwarded to the hook. You can still override any option at the hook level or at the state updater call level:

// Hook-level overrides
const [state, setState] = useQueryStates(searchParams, {
  shallow: true, // overrides the unified `shallow: false`
  urlKeys: {
    // overrides the unified urlKey for `search`
    search: 'usersSearch'
  }
})

// Call-level overrides
setState({ page: 2 }, { history: 'push' })

Server-side loader

// app/search/page.tsx (Next.js example)
export default async function SearchPage({ searchParams: pageSearchParams }) {
  const { search, page } = await searchParams.load(pageSearchParams)
  // search: string, page: number
}

Serializer

searchParams.serialize('/search', { search: 'cats', page: 1 })
// => '/search?q=cats'  (page=1 ommitted because it's the default)

Standard Schema

Works with any Standard Schema compatible library:

const result = await searchParams['~standard'].validate({
  search: 'dogs',
  page: 3,
  parse: "don't validate" // will be ommitted
})
// result.value: { search: 'dogs', page: 3 }

Composition

.extend(parsers, options?)

Merge additional parsers into an existing definition. Accepts either a raw parser map or another unified object:

const base = defineSearchParams({ search: parseAsString.withDefault('') })

// Extend with raw parsers
const withPage = base.extend({ page: parseAsInteger })

// Extend with another unified object
const filters = defineSearchParams(
  { category: parseAsString },
  { urlKeys: { category: 'cat' } }
)
const full = base.extend(filters)

// Extend with another unified object + additional options
base.extend(filters, { scroll: true })

When extending, options are merged using an "opinionated wins" strategy:

Option Opinionated value Rationale
clearOnDefault false "Don't clear" is an explicit choice
history 'push' "Push" is an explicit choice
shallow false "Notify the server" (for SSR) is explicit
scroll true "Scroll to the top" is explicit

For urlKeys, values are spread-merged, with the extension's keys replacing the base's in case of conflicts.

.pick(keys)

Create a subset from an existing definition, inheriting all options:

const searchParams = defineSearchParams({
  search: parseAsString.withDefault(''),
  page: parseAsInteger,
  sort: parseAsString
})

// Only use search and page in this component
const subset = searchParams.pick({ search: true, page: true })

// Equivalent of:
// const subset = defineSearchParams({
//   search: parseAsString,
//   page: parseAsInteger,
// })

Type Inference

inferParserType

Works directly on a unified object, no need to access .parsers:

import { type inferParserType } from 'nuqs'

type SearchParams = inferParserType<typeof searchParams>
// { search: string, page: number }

$infer helper

A convenience type helper is available on the unified object:

type SearchParams = typeof searchParams.$infer
// { search: string, page: number }

Note: $infer is a type-only property. Accessing it at runtime throws a
TypeError.

Option Cascade (Priority Order)

When using defineSearchParams with useQueryStates, options resolve in this order (highest priority first):

Call-level  >  Hook-level  >  Parser-level  >  Unified-level  >  Adapter  >  Default
Option Cascade
history call > hook > parser > unified > 'replace'
shallow call > hook > parser > unified > adapter > true
scroll call > hook > parser > unified > adapter > false
clearOnDefault call > parser > hook > unified > adapter > true
startTransition call > hook > parser
limitUrlUpdates debounce at any level forces debounce; timeMs cascades
urlKeys { ...unified, ...hook } merge (hook wins per-key)

Note: clearOnDefault intentionally lets parser-level override hook-level, since it's a per-key semantic (a parser declaring clearOnDefault: true should override a blanket clearOnDefault: false at the hook level).

Migration

This is a non-breaking, additive API. Existing code using createLoader, createSerializer, createStandardSchemaV1, and useQueryStates with raw parser maps continues to work unchanged.

To adopt defineSearchParams, consolidate your parser and option declarations:

-const parsers = { search: parseAsString, page: parseAsInteger }
-const loader = createLoader(parsers, { urlKeys: { search: 'q' } })
-const serializer = createSerializer(parsers, { urlKeys: { search: 'q' } })
+const searchParams = defineSearchParams(
+  { search: parseAsString, page: parseAsInteger },
+  { urlKeys: { search: 'q' } }
+)

// Server
-const data = loader(url)
+const data = searchParams.load(url)

// Client
-const [state, setState] = useQueryStates(parsers, { urlKeys: { search: 'q' } })
+const [state, setState] = useQueryStates(searchParams)

// Serialization
-const url = serializer({ search: 'test', page: 1 })
+const url = searchParams.serialize({ search: 'test', page: 1 })

@franky47 franky47 added the feature New feature or request label Mar 5, 2026
@franky47 franky47 added this to the 🪵 Backlog milestone Mar 5, 2026
@vercel
Copy link
Copy Markdown

vercel bot commented Mar 5, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
nuqs Ready Ready Preview, Comment Mar 6, 2026 8:03pm

Request Review

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 5, 2026

pnpm add https://pkg.pr.new/nuqs@1350

commit: 7bd0099

Having two passes of bundling caused the .d.ts definition of the $unified Symbol
to be duplicated, causing incompatible types (unified objects couldn't be used in
useQueryStates).

Changing the tsdown config to do a single pass of bundling solves this.
Changed the type-level test to match the best practice of defining unified
objects from "nuqs/server" and forwarding them to useQueryStates imported
from "nuqs".
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant