nuqs Adapter
Derive nuqs parsers from a route's search codecs.
nuqs is the standard tool for writing search-param
state from client components — useQueryState(s) with batched updates,
history control, and shallow routing. What it asks of you is a parser per
key. @paramour-js/nuqs derives those parsers from the codecs your route
already declares, so URL state is never defined twice.
npm install @paramour-js/nuqs nuqsA filter panel from a route
nuqsParsers(route) turns a route's whole search config into an ordinary
nuqs parser map:
// @filename: app/products/route.def.ts
import { , } from "paramour";
export const = ("/products", {
: {
: .().(1),
: .().(),
: .(),
},
});
// @filename: app/products/filter-panel.tsx
"use client";
import { } from "@paramour-js/nuqs";
import { } from "nuqs";
import { } from "./route.def";
export function () {
const [{ , , }, ] = (
(),
);
return (
<>
<
={() => void ({ : 1, : .. })}
={ ?? ""}
/>
< ={() => void ({ : + 1 })}>
next page ({.} tags active)
</>
</>
);
}Everything the codecs declared carries over: page reads non-nullable
(its .default(1) became withDefault), q reads string | null (nuqs's
spelling of absent), tags is a repeated-key multi-parser that reads []
when absent, and every value round-trips through the same
wire format your server parse expects. When
the route's config changes, this component's types change with it.
The output is ordinary nuqs currency — withOptions, createSerializer,
createLoader, and the server cache all compose untouched. The adapter
imports from nuqs/server internally, so deriving parsers is safe in server
code too.
Single-codec derivation
nuqsParser(codec) is the one-key form, useful when a component owns a
single param:
// @filename: app/products/route.def.ts
import { , } from "paramour";
export const = ("/products", {
: { : .().(1), : .().() },
});
// @filename: app/products/search-box.tsx
"use client";
import { } from "@paramour-js/nuqs";
import { } from "nuqs";
import { } from "paramour";
export function () {
const [, ] = ("q", (.().()));
return < ={() => void (..)} ={ ?? ""} />;
}The derivation rules
The adapter reads presence and defaults off the codec so the nuqs reading matches core's decode:
| Codec shape | nuqs parser |
|---|---|
required or .optional() | nullable read (null = absent) |
.default(value) | withDefault(value) — non-nullable read |
.default(() => value) (factory) | nullable — a frozen default would lie |
.catch(fallback) | malformed reads recover to the fallback |
.optional().catch(undefined) | malformed reads recover to null (absent) |
p.array(…) | multi (repeated-key) parser, [] when absent |
What refuses to compile
nuqs reserves null to mean "absent or unparseable" — so a codec whose
legitimate output includes null has no faithful nuqs twin, and the
adapter rejects it at the call site with the reason in the error, rather
than silently conflating your null with nuqs's:
import { } from "@paramour-js/nuqs";
import { } from "paramour";
const = .<null | string>({
: () => ( === "null" ? null : ),
: () => ?? "null",
});
(maybe);rawSearch routes are rejected the same way (there are no per-key codecs
to derive from), as are search-less routes. Plain-JavaScript callers get the
same judgments as loud runtime errors.
Defaults computed at render time
A value-form .default() is fixed when the route is defined. When the
default depends on the clock or on props — "this week" for a date range —
the route can't declare it. Keep the key .optional() and add the default
to the derived parser with nuqs's own withDefault, inside the component:
"use client";
import { nuqsParsers } from "@paramour-js/nuqs";
import { useQueryStates } from "nuqs";
import { useMemo } from "react";
import { gridRoute } from "./route.def";
const gridParsers = nuqsParsers(gridRoute); // from: p.string().optional(), …
export function useGridRange() {
const defaults = useMemo(() => currentWeek(), []);
const parsers = useMemo(
() => ({
...gridParsers,
from: gridParsers.from.withDefault(defaults.from),
to: gridParsers.to.withDefault(defaults.to),
}),
[defaults],
);
return useQueryStates(parsers);
}The derived parser keeps its wire-form eq, so nuqs's clearOnDefault
drops from/to from the URL whenever they equal this render's default.
Your setters pass plain values, with no value === default ? null : value
logic, and reads are non-nullable. On the server the key is still
.optional(), so apply the same default where you parse the route.
Keeping URLs nuqs already wrote
p.csv is nuqs-compatible for lists of non-empty elements that contain no
commas.
nuqs's parseAsArrayOf escapes an in-element comma as %2C and drops
elements that fail to parse, while p.csv rejects the comma and fails the
whole key. If you are moving off hand-written nuqs parsers and existing
bookmarks must keep working, use
nuqsArrayOf, which reads and writes
nuqs's format exactly:
import { nuqsArrayOf } from "@paramour-js/nuqs";
import { defineAppRoute, p } from "paramour";
export const reportRoute = defineAppRoute("/report", {
search: {
clients: nuqsArrayOf().default([]), // "Acme, Inc." fits
types: nuqsArrayOf(p.enum(["client", "internal"])).default([]),
},
});Server and client, one contract
The point of deriving rather than declaring: your server components
parse with the route, your client components
set state through parsers derived from the route, and
standardSearchSchema
exports the same contract to tRPC and friends. One search config, every
consumer.