Build and review query state with nuqs
Treat the URL as the state contract. Follow the steps in order, and load only the reference branches whose conditions match the application.
1. Model the URL contract
For every query key, record:
- its URL spelling and domain type;
- the value to use when the key is absent or invalid;
- whether setting the default should remove the key;
- whether an update represents navigation or ephemeral UI state;
- which client components, loaders, routes, or server components read it.
Inspect the installed nuqs, framework, and router major versions before
choosing integration paths.
Read the matching branch references:
- Adapter branch: select and mount the matching adapter when installing nuqs, changing routers, or reviewing the provider boundary.
- URL-key branch: define shorter external key names when domain property names should differ from URL keys.
- Provider-policy branch: configure adapter defaults and URL middleware when update policy or URL processing belongs at the application boundary.
Complete when every query key has one documented URL representation and the adapter import and provider boundary match the installed router.
2. Define one parser contract
Put related parsers in a dependency-neutral module. Import parsers from
nuqs/server when any server-side consumer imports that module; client hooks
can reuse the exported parser object.
Prefer built-ins that match the wire format. Use .withDefault(value) only
when absent and invalid values should resolve to a non-null domain default.
Defaults stay internal unless explicitly written; setting state to null
removes the query key.
import {
parseAsInteger,
parseAsString,
parseAsStringLiteral,
} from "nuqs/server";
export const searchParams = {
q: parseAsString.withDefault(""),
page: parseAsInteger.withDefault(1),
sort: parseAsStringLiteral(["relevance", "date"] as const).withDefault(
"relevance",
),
};
Create a custom parser only for a wire format that built-ins cannot express.
Its parse function returns null for invalid input, and parsing and
serialization form a pure, lossless round trip for every valid value.
Read the matching branch references:
- Scalar branch: choose typed parsers, non-null defaults, or literal and enum parsers from the domain type and absent-value policy.
- Collection branch: choose the array wire format or runtime-validated JSON from the URL contract.
- Special-format branch: use the documented date, one-based index, or hex color representation when the domain requires it.
- Custom-parser branch: follow the custom parser contract and provide object equality when value equality is not referential.
- Shared-module branch: share one parser map and use server-safe imports when both client and server code consume it.
- Schema branch: expose a Standard Schema validator when a router or validation library needs the same contract.
Complete when every key has exactly one parser, nullability follows its default policy, invalid input has an explicit outcome, and all consumers import the same parser definition.
3. Bind the contract to client state
Use useQueryState for one independent key. Use useQueryStates when keys form
one state object or must update atomically. Keep the URL-backed value as the
source of truth; isolate any temporary input draft and define when it commits
back to the URL. Client hook modules carry the 'use client' directive where
the framework requires it.
Choose update semantics deliberately:
- Keep the default
history: 'replace'for ephemeral state. Use'push'when each change should become a Back-button navigation point. - Keep shallow client-first updates when the server does not need the new
value. Use
shallow: falseonly when an update must rerun a server component or route loader. - Enable
scrollonly for navigation that should move the viewport. - Keep the default
clearOnDefault: truefor canonical URLs. Set it tofalseonly when the URL must preserve an explicitly written default value. - Use
limitUrlUpdates: throttle(ms)to bound repeated URL or server updates. Usedebounce(ms)for server-side fetching after the user pauses; debounce the hook value separately for client-side fetching. - Resolve option conflicts by precedence: setter call, parser, then hook-level options.
Read the matching branch references:
- Hook branch: load the client directive rule or grouped-state pattern when the component boundary or key relationship requires it.
- Update branch: use functional updates,
clear with
null, and the setter return value for derived or coordinated writes. - UI-state branch: normalize controlled input values and avoid mirroring URL state when local state could create a second source of truth.
- Option branch: use parser-level options, rate limiting, and default removal when policy applies to repeated writes.
- Rendering branch: isolate URL-state rerenders when profiling shows unrelated expensive work, and serialize typed links when navigation code constructs URLs.
- History branch: choose push for navigation or replace for ephemeral state, then verify scroll behavior and Back/Forward synchronization.
- Optimistic-router branch: use optimistic search params when Remix or React Router loaders need immediate client feedback.
Complete when each updater has explicit removal, history, server-notification, scroll, and rate-limit behavior for every key it can change.
4. Parse at server boundaries when present
Reuse the parser contract at every loader, request handler, route validator, or
Server Component that reads query state. Prefer createLoader for entry-point
parsing. Use createSearchParamsCache only when nested Next.js Server
Components need parsed values without prop drilling. When there is no
server-side reader, confirm that the query-state behavior is intentionally
client-first.
Read the matching branch references:
- Entry-boundary branch: use createLoader for one-off parsing and its supported input shapes.
- Nested-RSC branch: use createSearchParamsCache when a Next.js App Router page must expose parsed values below its boundary.
- Shared-contract branch: share parsers across client and server instead of defining a second server-only contract.
- Refresh branch: use
shallow: falseonly when the update must rerun server work, and expose pending UI withuseTransitionwhen needed. - Next.js async branch: handle
promise-based
searchParamson supported Next.js versions.
Complete when every server boundary parses the shared contract before reading values, or every key is confirmed to have no server consumer.
5. Verify the contract
Run the application's focused typecheck and tests. Exercise representative
URLs for absent, valid, empty, and invalid values, then update and clear every
key. Verify reload, copied-link, Back/Forward, and server refresh behavior for
the selected options. For high-frequency shallow: false updates, also verify
the request cadence.
Read the matching branch references:
- Failure branch: enable debug logging and follow the common-error diagnoses when behavior differs from the contract.
- Test branch: use the nuqs testing adapter patterns for component, hook, parser, and URL-update assertions.
Complete when tests and manual checks account for every query key and prove its parse, serialize, default, clear, navigation, and server-notification behavior.