paramour
Guides

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:

app/products/route.def.ts
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=banana degrades 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:

app/products/page.tsx
// @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.;
const result: {
    data: InferSearchOutput<{
        readonly page: Codec<number, "defaulted", false, "single", true>;
        readonly q: Codec<string, "optional", false, "single", boolean>;
        readonly sort: Codec<"price" | "rating" | "newest", "required", true, "single", boolean>;
        readonly tags: Codec<string[], "required", false, "single", boolean>;
    }>;
    status: "success";
}
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.

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:

BuilderWire shapeModifiers
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:

app/api/products/route.ts
// @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.

On this page