Search helpers
Standalone search-param decoding and encoding, the rawSearch escape hatch, and Standard Schema interop.
The route methods wrap these helpers; they're public for every call site
that holds a search config or route without page props — route handlers,
middleware, tests, derived tooling. One asymmetry to know up front: the
encode/decode primitives (decodeSearch, encodeSearch, searchToString)
take the search config itself, while the safe variants
(safeDecodeSearch, like safeDecodeParams) take the route object.
The exact wire behavior these helpers implement — absence vs. the empty string, ordering, duplicate keys, elision — is the wire-format spec's numbered rule set; this page documents the function contracts.
decodeSearch
Decodes search params against a config, from either source shape — a
URLSearchParams (route handlers, middleware) or Next's searchParams
record (an awaited props member):
import { , } from "paramour";
const = {
: .().(1),
: .().(),
};
const fromUrl = (, new ("?page=2"));
const = (, { : "2", : "socks" });Decode semantics, per key:
- Unknown keys are ignored — source values are only read for declared
keys, so
?utm_source=…junk can never fail a decode. - A missing required key is an issue; a missing optional key decodes to
undefined(the key is still present on the output); a missing defaulted key gets its default. - Array codecs (
p.array) consume every value for their key in wire order; absent means[]. - Duplicate values for a single-value codec are an issue — never silently disambiguated to first-or-last.
.catch()recovers parse failures per key (and per element, for arrays); absence and shape problems are never caught.
Failures aggregate — one Issue
per failed key — into a single
SearchDecodeError. A
declared key whose source value isn't a string or string[] is
different: that source is violating its own contract, and it throws a loud
SearchSourceError instead.
A rawSearch config branches to the whole-object schema path:
every source key reaches the schema, which owns stripping or passing
through extras itself.
safeDecodeSearch
The SafeResult twin — takes the
route object, returns instead of throwing:
import { , , } from "paramour";
const = ("/products", {
: { : .().(1), : .() },
});
const = (
,
new ("?page=abc"),
);
if (. === "error") {
..issues;}Only a SearchDecodeError becomes the error arm; source-contract
violations, foreign errors, and async-schema misuse stay loud.
encodeSearch
Encodes an input object to ordered wire pairs — the decoded value layer, before any percent-encoding:
import { , } from "paramour";
const = {
: .().(1),
: .(),
};
const = (, { : 3, : ["sale", "new"] });
// => [["page", "3"], ["tags", "sale,new"]]Encode semantics:
- Deterministic order: config declaration order, array elements in
order. (One JS caveat: integer-like keys such as
"0"enumerate first in numeric order regardless of declaration.) - An absent optional or defaulted key is omitted; an absent array key means
[]and produces nothing; a missing required key throwsSerializeError. - Default elision: a value equal to its value-form
.default()(by serialized wire form) is omitted, so every state has exactly one URL. Factory defaults never elide — a time-varying factory would elide an explicit value that later decodes differently.
For a rawSearch config there is no serializer to run: the
caller's already-wire-shaped record passes straight through, one pair per
string value and one per array element — the schema never runs on encode.
buildSearchString
The byte layer: turns wire pairs into the final ?… string.
import { } from "paramour";
const = ([
["q", "wool socks"],
["tags", "sale,new"],
]);
// => "?q=wool%20socks&tags=sale%2Cnew"Hand-rolled on purpose — this is deliberately not
URLSearchParams#toString, which emits + for a space. Paramour emits
%20, and encoding is encodeURIComponent-strict, so a decoded URL means
the same thing everywhere. An empty pair set returns "" (no lone ?);
unencodable text (lone surrogates) throws SerializeError.
searchToString
encodeSearch + buildSearchString in one step — the exact query half
href builds:
import { , } from "paramour";
const = {
: .().(1),
: .().(),
};
const = (, { : 1, : "wool socks" });
// => "?q=wool%20socks"(page: 1 equals its default, so it elided.) Use the two smaller pieces
directly if you need to intervene between the value layer and the byte
layer.
rawSearch
The whole-object escape hatch: wraps a bare Standard Schema so a route's
search: slot hands the entire search object to one schema of yours,
bypassing per-key codecs:
import { , } from "paramour";
import * as from "valibot";
export const = ("/legacy", {
: (.({ : .(), : .() })),
});
declare const : import("paramour").;
const search = await .();The schema receives every source key (the unknown-keys-are-ignored rule
does not apply — a whole-object schema owns stripping or passing through
extras), normalized to Next's own searchParams shape: one value as
string, repeats as string[], from either source kind. Validation is
sync-only; an async validate throws.
The trade is explicit, and it's why this is a greppable wrapper rather than
a bare search: schema:
- No per-key
.default()/.catch()— those are codec modifiers, and there are no codecs here. - No round-trip encoding — the schema never runs on encode, so
hrefaccepts only the raw wire record (Record<string, string | string[]>) for such a route. If you need bidirectional per-key transforms, that'sp.custom.
A schema failure still surfaces as the standard SearchDecodeError with
issues[]; a root-level schema issue is keyed "<search>".
isRawSearch
The runtime discriminant for a route's search: slot — a type guard
returning true when the value is the marker rawSearch produced, false
for a codec map. Exported so derived surfaces (the
nuqs adapter, devtools) can branch on the slot's shape
without duplicating the marker literal; app code rarely needs it.
standardSearchSchema
The interop seam in the other direction: exports a route's search: config
as a spec-compliant Standard Schema, for consumers like tRPC inputs or
TanStack Router's validateSearch:
import { , , } from "paramour";
const = ("/products", {
: { : .().(1), : .().() },
});
const searchSchema = ();Hand searchSchema to anything that speaks the spec and it enforces the
same URL contract as your pages, from one definition. The semantics are
byte-identical to decodeSearch:
- Defaults apply, unknown keys strip, duplicate values on a single-value codec reject.
.catch()recovers — which means invalid API input under a caught key silently coerces to the fallback. That's the same contract your pages see, but worth knowing at an API boundary.- No coercion, ever: the schema accepts wire strings (
"42"), not decoded values (42).
The advertised input type is the wire-shaped record only —
URLSearchParams is accepted at runtime but kept out of the type, so
client-side inference (tRPC) never sees a shape that can't serialize over
JSON. Because validate receives genuinely untrusted input, source-shape
violations soften to spec issues at this one boundary instead of throwing;
config mistakes and throwing validators stay loud, and a malformed config
fails at construction, not at first validate().
Low-level primitives
serializeValue
Invokes a codec's element serializer under the string contract — the one
implementation of "turn this value into its wire string" that search
encoding, path-segment encoding, and derived surfaces (the nuqs adapter's
equality/clearOnDefault checks) all share, so their judgment stays
identical to default-elision's by construction:
import { , } from "paramour";
const = (
.(),
'search param "since"',
new ("2026-07-18T00:00:00Z"),
);
// => "2026-07-18"The label names the value's site in error messages. A serializer
returning anything but a string throws SerializeError — a plain-JS custom
codec returning undefined must not reach the byte layer as the literal
text "undefined". For arity-"many" codecs this serializes one
element, not the whole array. This is a tooling/advanced surface — apps
building links should be calling href.
Its parse-side twin, parseValue, is not exported from the package barrel;
tooling authors will find it under paramour/internal.
Related types
| Type | What it is |
|---|---|
SearchConfig | A codec-map search config: Record<string, AnyCodec>. |
SearchSource | What the decoders accept: Next's searchParams record or a URLSearchParams. Both arrive already percent-decoded. |
SearchOutputOf<SC> | The decoded output type of a search: slot — codec map and rawSearch alike. What parseSearch resolves to. |
InferSearchInput<S> | The encode-side input of a codec map: required keys stay required; optional, defaulted, and array keys may be omitted. |
InferSearchOutput<S> | The parse-side output of a codec map: every declared key present; optional presence adds undefined to the value type. |
RawSearch<S> | The branded marker rawSearch returns — what a route's search: slot holds in raw mode. |
StandardSearchSchema<SC> | The schema type standardSearchSchema returns: wire-shaped record in, decoded search out. |