Skip to content

feat: unified API - #1350

Draft
franky47 wants to merge 19 commits into
nextfrom
feat/unified-api
Draft

feat: unified API#1350
franky47 wants to merge 19 commits into
nextfrom
feat/unified-api

Conversation

@franky47

@franky47 franky47 commented Mar 5, 2026

Copy link
Copy Markdown
Member

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

vercel Bot commented Mar 5, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
nuqs Ready Ready Preview Aug 7, 2026 11:00pm

@pkg-pr-new

pkg-pr-new Bot commented Mar 5, 2026

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

commit: 481ac0e

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".
@sss-Mihail-sss

Copy link
Copy Markdown

These updates look very promising. I hope the next version comes out soon. 👌

# Conflicts:
#	packages/nuqs/src/api.test.ts
#	packages/nuqs/src/index.server.ts
#	packages/nuqs/src/index.ts
#	packages/nuqs/src/useQueryStates.ts
#	packages/nuqs/tsdown.config.ts
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e7311cb7-04ac-4728-9d80-78f7d66d5d4a

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

Bundle size

Base PR Δ
✅ Client 5.79 kB 6.21 kB ⬆️ +425 B
✅ Client (minimal tree-shaken) 4.22 kB 4.31 kB ⬆️ +93 B
✅ Server 3.70 kB 4.05 kB ⬆️ +361 B

Brotli-compressed. Limits are set in the size-limit config in packages/nuqs/package.json.

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.

2 participants