Search Params in the App Router
Validated, serializable search params with codec modifiers.
Search params are where URL state actually lives — filters, pagination, search text, view modes. This guide covers declaring them, reading them in server components and route handlers, and building links that serialize them correctly.
Declaring a search config
Each key gets a codec; modifiers express the presence policy:
import { , } from "paramour";
export const = ("/products", {
: {
: .().(1),
: .().(),
: .(["price", "rating", "newest"]).("newest"),
: .(),
},
});Choosing a modifier is choosing what "absent" and "malformed" mean per key:
.optional()when absence is meaningful — "no search text" is a real state your code checks for (q?: string)..default(v)when absence means a canonical value — nobody handles "no page"; page one is the absence. Bonus: values equal to the default elide from URLs..catch(v)when a malformed value shouldn't take the page down — a hand-edited?sort=bananadegrades to"newest"instead of erroring.- Bare when the param is required and malformed input should fail the decode loudly.
Reading in a server component
safeParse (or parseSearch if you don't need params) — render the error
arm rather than crashing on a hand-edited URL:
// @filename: app/products/route.def.ts
import { , } from "paramour";
export const = ("/products", {
: {
: .().(1),
: .().(),
: .(["price", "rating", "newest"]).("newest"),
: .(),
},
});
// @filename: app/products/page.tsx
import type { RouteProps } from "paramour";
import { } from "./route.def";
export default async function (: RouteProps) {
const = await .();
if (. === "error") {
return <>Bad filter URL: {..[0]?.}</>;
}
const { , , , } = result.; return (
<>
Page {}, sorted by {}
{ && <> — matching “{}”</>} ({.} tag filters)
</>
);
}Note what didn't need handling: page and sort are always present
(default and catch), tags is always an array, and unknown keys
(?utm_source=…) are ignored by the decode — none of them can produce the
error arm.
Building links
href takes the same search shape, serializes each value through its codec,
and omits what should be omitted (absent optionals, values equal to their
default):
const = (, {
: { : 1, : "price", : ["sale"] },
: "results",
});
// => "/products?sort=price&tags=sale#results"A filter UI that renders <Link href={...}> per option gets canonical URLs
for free — no ?page=1 cache-splitting, no manually-assembled query
strings.
List params: p.array vs p.csv
Same in-memory type (string[], or T[] with an element codec), two wire
shapes:
| Builder | Wire shape | Modifiers |
|---|---|---|
p.array() | ?tags=sale&tags=new (repeated key) | none — absent is [] |
p.csv() | ?tags=sale%2Cnew (one key) | full set available |
p.csv keeps one key per param and supports .catch()/.default();
p.array matches the repeated-key convention other stacks emit. Pick per
param — both round-trip exactly.
Outside pages: route handlers and middleware
Route handlers and middleware hold a URLSearchParams, not page props. The
standalone decoders take the same route object:
// @filename: app/products/route.def.ts
import { , } from "paramour";
export const = ("/products", {
: { : .().(1), : .().() },
});
// @filename: app/api/products/route.ts
import type { } from "next/server";
import { } from "next/server";
import { } from "paramour";
import { } from "../../products/route.def";
export function (: ) {
const = (, ..);
if (. === "error") {
return .({ : .. }, { : 400 });
}
return .({ : .. });
}An API endpoint and the page it backs now enforce the same URL contract
from the same definition. (encodeSearch/buildSearchString are the
serialize-side equivalents, and standardSearchSchema exports the contract
to Standard Schema consumers
like tRPC.)
The whole-object escape hatch
If per-key codecs genuinely don't fit — a legacy query shape, one schema
validating cross-key invariants — rawSearch hands the entire search object
to a single Standard Schema:
import { , } from "paramour";
import { } from "zod";
export const = ("/legacy", {
: (
.({ : .(), : .() }).(
() => . !== .,
{ : "from and to must differ" },
),
),
});The trade is explicit: no per-key defaults or .catch(), and no round-trip
encoding — href can't serialize what only a validator understands. It's a
deliberate, greppable act, not a fallback paramour ever picks for you.
On the client
Reading search params in client components is the hooks guide; setting them as client state — the filter-panel use case — is what the nuqs adapter is for.