# What is Paramour? URL: https://paramour.dev/docs Paramour gives Next.js apps validated route and search params, typed path building, and an explicit, predictable URL wire format — with bring-your-own-validator support through Standard Schema. * **Getting Started** — install paramour and define your first typed route. * **Concepts** — how codecs, route objects, serialization, and the generated registry fit together. * **Guides** — task-oriented recipes for routes, search params, hooks, and the CLI. * **Reference** — per-package API documentation and the wire-format spec. * **Migrate** — moving from `next-typesafe-url`. --- # Codecs & the Type-State API URL: https://paramour.dev/docs/concepts/codecs Codecs are paramour's core primitive: bidirectional converters between typed values and URL strings, whose modifier methods (`.optional()`, `.default()`, `.catch()`) are tracked in the type system so invalid chains fail to compile. ## Why codecs exist [#why-codecs-exist] A URL is made of strings. Your program is not. Something has to own the conversion in *both* directions — decoding `"42"` into the number `42` when a request comes in, and serializing `42` back into `"42"` when you build a link. Validation libraries only cover half of this. A Zod or Valibot schema can check a value, but it cannot tell you how a `Date` should look in a URL, or turn one back into a string. That gap is the reason paramour exists: every param and search param is described by a **codec**, a small object that knows how to parse a wire string and how to serialize a value — and both directions are strict. Malformed input fails with a structured error instead of becoming `NaN`; an unserializable value fails at link-build time instead of producing a URL that can never round-trip. Decoding is strict in a way `Number()` never was: each codec matches an anchored grammar, so `"1.5"` is not an integer, `" 42"` is not a number, and `"0x10"` is not sixteen. ## The catalog [#the-catalog] You'll rarely build a codec by hand — the `p.*` builders cover the useful wire shapes: ```ts twoslash import { defineAppRoute, p } from "paramour"; const eventsRoute = defineAppRoute("/events/[date]", { params: { date: p.isoDate() }, search: { limit: p.integer().optional(), tags: p.array(), view: p.enum(["day", "week", "month"]).default("month"), }, }); declare const props: import("paramour").RouteProps; const { params, search } = await eventsRoute.parse(props); params.date; // ^? search.view; // ^? search.tags; // ^? ``` **Scalars.** `p.string()`, `p.number()`, `p.integer()`, `p.boolean()` (exactly `"true"`/`"false"` on the wire), and `p.enum(["a", "b"])`, which narrows to the member union. The string and numeric builders optionally take a [Standard Schema](/docs/concepts/standard-schema) for domain refinement. **Temporal.** `p.isoDate()` decodes `2026-07-18` into a UTC-midnight `Date` and rejects dates that don't exist on a calendar (`2026-02-30` fails rather than silently becoming March 2nd). `p.timestamp()` handles full ISO-8601 UTC instants (`2026-07-18T12:30:00.000Z`) with the same no-silent-normalization stance. **Lists.** Two first-class wire shapes for the same in-memory type: `p.array()` repeats the key (`?tags=a&tags=b`), while `p.csv()` packs one key (`?tags=a,b`). Both take an optional element codec — `p.csv(p.integer())` gives you `number[]`. The [serialization page](/docs/concepts/serialization) covers how the two differ on the wire. **Structured.** `p.json(schema)` round-trips any JSON value through a single param, validated by the schema you pass (required here — unvalidated `JSON.parse` output would poison the types). `p.index()` is a pagination-friendly integer that is 1-based on the wire and 0-based in memory: `?page=1` decodes to index `0`. **Escape hatches.** `p.custom({ parse, serialize })` wraps any bidirectional pair of functions; `createCodec` is the lower-level factory the `p.*` builders themselves use. Reach for these when your wire shape isn't covered — not to skip validation. ## Modifiers [#modifiers] Three chainable modifiers adjust presence and failure behavior: ```ts twoslash import { p } from "paramour"; const q = p.string().optional(); const page = p.integer().default(1); const view = p.enum(["grid", "list"]).catch("grid"); ``` * **`.optional()`** — the param may be absent; the decoded type gains `| undefined`, and serializing `undefined` omits the key. * **`.default(value)`** — absent decodes as `value`, and (in the other direction) serializing a value equal to the default **elides** it from the URL, so there's exactly one URL per state. The factory form (`.default(() => new Date())`) computes a fresh default per decode — and deliberately never elides, since a time-varying default could swallow an explicitly-passed value. * **`.catch(fallback)`** — a malformed wire value recovers to `fallback` instead of failing the decode. Use it for params you'd rather degrade than 404 on (a view mode, a sort order). It recovers *parse* failures only: misconfiguration and serialization bugs stay loud. ## The type-state system [#the-type-state-system] Some modifier combinations are contradictions. What should `.optional().default(1)` mean — absent, or `1`? Can a `.catch()` have two fallbacks? Many libraries answer with runtime throws or silently-last-wins. Paramour answers at compile time: each codec's type tracks which modifiers have been applied, and an illegal chain makes the *method itself* unavailable — so the mistake is a red squiggle, not a production incident: ```ts twoslash // @errors: 2349 import { p } from "paramour"; p.string().optional().optional(); p.string().optional().default("a"); p.array().default(["a"]); p.integer().catch(0).catch(1); ``` Each error reads oddly ("This expression is not callable… type 'never'") because the illegal modifier has literally been removed from the type. The third case is worth dwelling on: `p.array()` rejects `.default()` because an absent repeated-key param already decodes as `[]` — a default could never be observed. These rules aren't types-only theater. The same chains throw at runtime for plain-JavaScript consumers — the type-state is a compile-time mirror of real constraints. And this documentation is itself compiled: the squiggles above come from the real packages, the same way the repo's own type tests pin them. ## Where codecs come from and go [#where-codecs-come-from-and-go] Codecs are inert descriptions — defining one does nothing until a [route object](/docs/concepts/route-objects) carries it to a parse call, an `href`, a [hook](/docs/guides/hooks), or the [nuqs adapter](/docs/guides/nuqs). Their exact wire behavior is specified, rule by rule, in [Serialization & the Wire Format](/docs/concepts/serialization). For the full API surface, entry by entry, see the [`p.*` builders reference](/docs/reference/core/p-builders) and the [Codec & modifiers reference](/docs/reference/core/codecs). --- # The Generated Route Registry URL: https://paramour.dev/docs/concepts/registry `paramour generate` scans your app directory and emits a types-only module augmentation, so defining a route whose path doesn't exist on disk becomes a compile error — with zero runtime cost. ## The problem it solves [#the-problem-it-solves] A [route object](/docs/concepts/route-objects) declares its path as a string: `defineAppRoute("/product/[id]", …)`. The compiler can check everything *about* that string — params matching segments, codecs matching types — but it cannot know whether `app/product/[id]/page.tsx` actually exists. Filesystem truth lives outside the type system. The registry imports it. `paramour generate` scans `app/` (and `pages/`, if present) and writes `paramour-env.d.ts`: ```ts twoslash title="paramour-env.d.ts" // Generated by @paramour-js/next. Do not edit — regenerate with `paramour generate`. import "paramour"; declare module "paramour" { interface ParamourRegister { appRoutes: "/" | "/product/[id]"; } } ``` That's the whole artifact: a types-only module augmentation teaching the compiler which paths exist, per router. No generated runtime code, no build step in your bundle — it compiles to nothing. ## What it buys [#what-it-buys] Once the augmentation is in your program, the route constructors' path argument narrows from "any plausible string" to the registered union. Typos, renamed directories, and router mix-ups all become squiggles: ```ts twoslash // @errors: 2345 // @filename: paramour-env.d.ts import "paramour"; declare module "paramour" { interface ParamourRegister { appRoutes: "/" | "/product/[id]"; pagesRoutes: "/legacy/[id]"; } } // @filename: routes.ts import { defineAppRoute, definePagesRoute, p } from "paramour"; defineAppRoute("/totally/made/up", {}); defineAppRoute("/product/[productId]", { params: { productId: p.integer() }, }); definePagesRoute("/product/[id]", { params: { id: p.integer() } }); ``` The middle one is the case that pays rent: `/product/[id]` exists, but the definition says `[productId]` — exactly the near-miss that survives code review and fails at 2 a.m. The last shows the router gate: `/product/[id]` is an App route, so the Pages constructor rejects it. Before the first generate, no augmentation exists and the constructors accept any well-formed path — the library degrades gracefully rather than blocking you. The registry is what upgrades "plausible" to "real." ## The lifecycle [#the-lifecycle] The artifact is **generated and committed** — treat it like a lockfile: 1. **Edit routes** — add, move, or delete a page directory. 2. **`paramour generate`** — rewrites `paramour-env.d.ts` (the `"paramour"` package script that [init](/docs/guides/cli#init) added runs exactly this). During `next dev`, the `withTypedRoutes` wrapper keeps it current automatically as routes appear and disappear. 3. **`paramour check` in CI** — re-scans and compares. On drift it exits non-zero and never writes: a route deleted on disk but still registered (or vice versa) fails the build instead of lying to the compiler. Committing the artifact means the registry works for everyone who checks out the repo — code review shows route changes as visible diffs to a small, readable file, and CI proves that diff honest. (Don't hand-edit or format it; it's regenerated verbatim.) This site practices what this page preaches: every docs page you're reading is served by paramour routes, and the docs build runs `paramour check` before `next build` — registry drift here fails our CI too. ## Where next [#where-next] * [CLI workflows](/docs/guides/cli) — `generate`, `check`, and the rest of the toolchain in depth, including watch mode and CI recipes. * [Registry types reference](/docs/reference/core/routes#registry-types) — `ParamourRegister` and the `Registered*Paths` lookups the artifact populates. * [Getting Started](/docs/getting-started) — the registry in the context of a full setup, if you landed here first. --- # Route Objects as Currency URL: https://paramour.dev/docs/concepts/route-objects Every paramour route is a plain exported object you import wherever you build links or parse props. There is no central runtime registry — routes are currency, passed by import. ## One definition, many consumers [#one-definition-many-consumers] A route object is created once, next to the page it describes: ```ts twoslash title="app/product/[id]/route.def.ts" import { defineAppRoute, p } from "paramour"; export const productRoute = defineAppRoute("/product/[id]", { params: { id: p.integer() }, search: { q: p.string().optional() }, }); ``` Everything else in the system *takes the object*: * **Server components** call `productRoute.parse(props)`. * **Client components** pass it to [`useRouteParams` / `useSearch`](/docs/guides/hooks). * **Links** are built with `href(productRoute, …)`. * **The [nuqs adapter](/docs/guides/nuqs)** derives client-state parsers from it. * **The [devtools panel](/docs/guides/devtools)** observes decodes through it. Because the route is the unit of exchange, the params/search contract is stated exactly once. Rename a param, change a codec, add a search key — every consumer's types update in the same keystroke, and anything now inconsistent fails to compile: ```ts twoslash import { defineAppRoute, href, p } from "paramour"; const productRoute = defineAppRoute("/product/[id]", { params: { id: p.integer() }, search: { q: p.string().optional() }, }); // ---cut--- const link = href(productRoute, { params: { id: 42 }, search: { q: "socks" } }); // ^? ``` ## Why not a string-keyed registry? [#why-not-a-string-keyed-registry] The other common design is a central map: register every route in one module, look routes up by name or path string. Paramour rejects that shape on purpose, for two reasons. **Tree-shaking.** A central registry is a single object that references every route in the app — import one route and you've imported all of them, plus every codec they mention. With route objects as currency, a page that links to two routes bundles exactly those two. There is no paramour runtime state anywhere: the library is only the functions you call and the objects you import. (The [generated registry](/docs/concepts/registry) is types-only and compiles to nothing.) **Refactorability.** An imported object participates in every tool your editor already has — go-to-definition, find-all-references, rename-symbol. A string key participates in none of them. ## Convention: `route.def.ts` beside the page [#convention-routedefts-beside-the-page] Route objects live in a `route.def.ts` file colocated with their `page.tsx`. The name is a convention, not a requirement — but colocation is worth keeping: the definition sits next to the file whose URL it describes, and moving the page directory moves its contract with it. One real constraint: definition files should be **import-safe** — free of side effects and importable outside Next.js — because `paramour list` and the [devtools panel](/docs/guides/devtools) evaluate them to inspect route shapes. A `route.def.ts` that only calls `defineAppRoute`/`definePagesRoute` is always safe; avoid dragging server-only modules into it. ## Two constructors, two surfaces [#two-constructors-two-surfaces] Next.js has two routers with genuinely different data flows, and paramour does not paper over that. `defineAppRoute` produces a route whose parse surface is async and props-based (`parse`, `parseParams`, `parseSearch`, and their `safe*` twins). `definePagesRoute` produces one whose surface is sync and context-based (`parseContext`, `safeParseContext`), for `getServerSideProps` and friends. The compiler enforces the split — an App-router method does not exist on a Pages route, and vice versa: ```ts twoslash // @errors: 2339 import { defineAppRoute, definePagesRoute, p } from "paramour"; const appRoute = defineAppRoute("/product/[id]", { params: { id: p.integer() }, }); const pagesRoute = definePagesRoute("/legacy/[id]", { params: { id: p.integer() }, }); pagesRoute.parse; appRoute.parseContext; ``` The same gate runs through the [hooks](/docs/guides/hooks): hooks from `@paramour-js/next/app` accept only App routes, hooks from `@paramour-js/next/pages` only Pages routes. Mixing them up is a compile error, not a hydration mystery. ## Where next [#where-next] * [Defining routes](/docs/guides/defining-routes) — recipes: catch-alls, multiple params, `parse` vs `safeParse`. * [The generated registry](/docs/concepts/registry) — how route *paths* become compile-time checked too. --- # Serialization & the Wire Format URL: https://paramour.dev/docs/concepts/serialization Standard Schema is validate-only, so paramour owns serialization: every codec defines exactly how values appear in the URL, per a numbered wire-format spec. ## URLs are public API [#urls-are-public-api] People copy URLs into Slack, bookmark them, and put them in support tickets. Other services parse them. Your analytics segment on them. A URL outlives the deploy that produced it — which makes its format a public contract, whether you treat it as one or not. Most routing setups leave that contract implicit: whatever `String(value)` happens to produce, whatever `URLSearchParams` happens to accept. Paramour's position is that every serialization decision should be explicit, documented, and stable. If you can predict the exact URL from the value — and the exact value from the URL — the format is doing its job. ## What values look like on the wire [#what-values-look-like-on-the-wire] Every example below is real output from the shipped library — the "On the wire" column is computed by the docs build, not hand-written: Two layers are visible here. The **value layer** is each codec's grammar — `true`/`false` for booleans, `YYYY-MM-DD` for dates, one repeated key per array element. The **byte layer** is percent-encoding, which paramour also pins down deliberately: spaces are `%20` (never `+`), and encoding is `encodeURIComponent`-strict, so a decoded URL means the same thing everywhere. ## Round-tripping [#round-tripping] The route methods and `href` do all of this for you, but the machinery is public — useful in route handlers, middleware, or anywhere you hold a search config without a request: ```ts twoslash import { decodeSearch, p, searchToString } from "paramour"; const search = { page: p.integer().default(1), since: p.isoDate().optional(), tags: p.csv(), }; const qs = searchToString(search, { page: 1, since: new Date("2026-07-18T00:00:00Z"), tags: ["sale", "new"], }); // => "?tags=sale%2Cnew&since=2026-07-18" const decoded = decodeSearch(search, new URLSearchParams(qs)); // ^? ``` (`searchToString` composes two smaller pieces — `encodeSearch` to ordered key/value pairs, `buildSearchString` to bytes — if you need to intervene between them.) Notice what happened to `page`. It was passed as `1`, equal to its `.default(1)` — so it was **elided**. Serializing a state and decoding the result always lands on the same state, and every state has exactly *one* canonical URL: no `?page=1` and `?` aliases of each other, no cache keys that differ by a default. (Only value-form defaults elide; factory defaults never do — see [modifiers](/docs/concepts/codecs#modifiers).) Absent optionals are omitted entirely, and on decode, keys paramour doesn't own are ignored: `?utm_source=…` can never fail your parse. ## When decoding fails [#when-decoding-fails] Strictness needs good failure ergonomics. A failed decode aggregates *every* bad key into one structured error — `issues[]`, one entry per key, not a throw on the first problem: ```ts twoslash import { defineAppRoute, p, safeDecodeSearch } from "paramour"; const productRoute = defineAppRoute("/product/[id]", { params: { id: p.integer() }, search: { page: p.integer().default(1), tags: p.csv() }, }); const result = safeDecodeSearch( productRoute, new URLSearchParams("?page=abc&tags=sale,new"), ); if (result.status === "error") { result.error.issues; // ^? // => [{ key: "page", message: '"abc" is not an integer' }] } ``` The same `issues[]` shape appears everywhere a decode can fail — route methods, hooks, the standalone helpers — so error rendering is written once. Per-key recovery is opt-in via [`.catch()`](/docs/concepts/codecs#modifiers). ## The normative spec [#the-normative-spec] Everything on this page is behavior; the letter of the law is the [wire-format spec](/docs/reference/wire-format) — every rule numbered and stable-anchored (`#s3`, `#cv4`, …), each pinned by the library's conformance test suite and illustrated with examples the docs build computes against the shipped package. When you need to know *exactly* what lands in the URL — absence vs. the empty string, duplicate keys, catch-all segments, csv grammar — the spec is the page to cite. The normative rules, numbered and anchored, with live-computed examples. Compose codecs, watch the URL serialize live, paste a query string, watch it decode — the whole wire format, interactive. --- # Standard Schema & BYO Validator URL: https://paramour.dev/docs/concepts/standard-schema Paramour codecs accept any Standard Schema validator for refinement, so you keep your existing validation library instead of learning a new one. ## A seam, not an adapter [#a-seam-not-an-adapter] [Standard Schema](https://standardschema.dev) is a tiny spec that validation libraries (Zod, Valibot, ArkType, and a growing list) all implement: one `~standard.validate` interface that any consumer can call without knowing which library produced the schema. Paramour consumes that interface directly. There is no `@paramour-js/zod`, no `@paramour-js/valibot`, and there never needs to be — the division of labor is: * **The codec owns the wire**: what the value looks like in the URL, how it parses, how it serializes. That knowledge is paramour's whole job, and validators don't have it (Standard Schema is validate-only — it has no serialize direction). * **Your schema owns the domain**: which *values* are acceptable once decoded. Minimum lengths, ranges, formats — rules about your data, written in the library you already use. ## Where a schema slots in [#where-a-schema-slots-in] The string and numeric builders take an optional schema; `p.json` requires one (unvalidated `JSON.parse` output would poison the types). The same route, refined with two different validators: ```ts twoslash import { defineAppRoute, p } from "paramour"; import { z } from "zod"; const slug = z.string().regex(/^[a-z0-9-]+$/); const limit = z.number().int().min(1).max(100); export const postsRoute = defineAppRoute("/posts/[slug]", { params: { slug: p.string(slug) }, search: { limit: p.integer(limit).default(20) }, }); declare const props: import("paramour").RouteProps; const { params, search } = await postsRoute.parse(props); params.slug; // ^? search.limit; // ^? ``` ```ts twoslash import { defineAppRoute, p } from "paramour"; import * as v from "valibot"; const slug = v.pipe(v.string(), v.regex(/^[a-z0-9-]+$/)); const limit = v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(100)); export const postsRoute = defineAppRoute("/posts/[slug]", { params: { slug: p.string(slug) }, search: { limit: p.integer(limit).default(20) }, }); declare const props: import("paramour").RouteProps; const { params, search } = await postsRoute.parse(props); params.slug; // ^? search.limit; // ^? ``` Note the layering in `p.integer(limit)`: the codec's grammar runs first (`"abc"` is rejected as not-an-integer before your schema ever sees it), then the schema refines the decoded number. `/posts/hello-world?limit=200` fails with a schema issue on `limit`; `?limit=abc` fails with a grammar issue. Either way it's the same structured `issues[]` you get from any [decode failure](/docs/concepts/serialization#when-decoding-fails). ## Refinement runs in both directions [#refinement-runs-in-both-directions] The schema validates on decode — and *again on serialize*. Building a link with a schema-invalid value throws at `href` time, in the component that made the mistake, rather than shipping a URL that will fail for whoever clicks it. This is the same ethos as the rest of the library: errors surface where the bad value is introduced, not two navigations later. One honest limitation follows from serialization being paramour's job: **transforming schemas are parse-only**. A schema whose output type differs from its input (say, string in, `URL` object out) can refine a decode, but its output would fail validation on the way back — there is no inverse-transform in Standard Schema to call. If you need a bidirectional transform, that's a wire concern, and the tool is [`p.custom`](/docs/concepts/codecs#the-catalog) — parse *and* serialize, stated explicitly. ## The seam runs the other way too [#the-seam-runs-the-other-way-too] Paramour can also *produce* a Standard Schema. `standardSearchSchema(route)` exports a route's entire search contract as a spec-compliant schema — wire strings in, decoded values out, byte-identical semantics to the route's own parse (defaults apply, `.catch()` recovers, unknown keys strip): ```ts twoslash import { defineAppRoute, p, standardSearchSchema } from "paramour"; const productsRoute = defineAppRoute("/products", { search: { page: p.integer().default(1), q: p.string().optional() }, }); const schema = standardSearchSchema(productsRoute); // ^? ``` Hand it to anything that speaks the spec — a tRPC input, TanStack Router's `validateSearch` — and those tools now enforce the same URL contract as your pages, from one definition. There's also an escape hatch in the opposite spirit: `rawSearch(schema)` hands a route's *whole* search slot to one schema of yours, bypassing per-key codecs entirely (and giving up round-trip encoding and per-key defaults in the bargain). The [search-params guide](/docs/guides/search-params) covers when that trade is worth it. --- # Getting Started URL: https://paramour.dev/docs/getting-started Paramour routes are plain objects: define one next to its page, and you get validated params and typed hrefs everywhere you import it. This tutorial takes you from an empty project to a working typed route — params, search params, client hooks, and the compile-time route registry — in about ten minutes. Every code block on this page is compiled against the real packages on every docs build. Hover anything to see what the compiler sees. ## Create a project [#create-a-project] Start from a fresh Next.js app (TypeScript and the App Router are the defaults): ```bash npx create-next-app@latest my-app cd my-app ``` Already have an App Router project? Skip ahead — nothing below assumes a clean slate except the route paths. ## Install [#install] Paramour is two packages: `paramour` (the core — codecs, route objects, `href`) and `@paramour-js/next` (the Next.js integration — hooks, build plugin, and the `paramour` CLI). npm pnpm yarn bun ```bash npm install paramour @paramour-js/next ``` ```bash pnpm add paramour @paramour-js/next ``` ```bash yarn add paramour @paramour-js/next ``` ```bash bun add paramour @paramour-js/next ``` ## Initialize [#initialize] One command wires everything up: ```bash npx paramour init ``` ```txt paramour init ✔ created paramour.config.ts ✔ wrapped next.config.ts with withTypedRoutes ✔ added "paramour" script to package.json ✔ wrote paramour-env.d.ts (1 route) • no agent tooling detected — skipped agent skills (`paramour skills` installs to the portable .agents/skills/) • no AGENTS.md or CLAUDE.md — skipped agents snippet setup: ✔ route directories: app/ ✔ dependencies declared: paramour, @paramour-js/next ✔ tsconfig.json includes paramour-env.d.ts Commit the generated artifact — `paramour check` verifies it stays current in CI. ``` ### What init just did [#what-init-just-did] Four things, each idempotent (rerunning init never clobbers your edits): 1. **Created `paramour.config.ts`** — CLI configuration. Every field in the scaffold is a commented-out default, so you can delete the file and nothing changes. You'll only touch it if your project uses non-standard directories. 2. **Wrapped `next.config.ts` with `withTypedRoutes`** — the build-time integration that keeps the generated registry in sync while `next dev` runs. 3. **Added a `"paramour"` script to `package.json`** — shorthand for `paramour generate`, which you'll run whenever you add or move a route. 4. **Ran the first generate** — wrote `paramour-env.d.ts`, a small generated file that teaches the compiler which routes exist. That file is the route registry; commit it like a lockfile. The [registry page](/docs/concepts/registry) explains how it works. The two skipped steps are for projects with coding agents in the loop: when init detects agent tooling (a `.claude/`, `.cursor/`, `.codex/`, or `.agents/` directory, or a root `AGENTS.md`), it also installs the bundled [agent skill](/docs/guides/ai-agents) and appends a paramour section to your agent instructions file — so your agent learns the library the moment you do. On a fresh `create-next-app` there's nothing to detect, hence the skips. ## Define your first route [#define-your-first-route] A paramour route lives in a `route.def.ts` file next to the page it describes. Create the page directory `app/product/[id]/` and define the route: ```ts twoslash title="app/product/[id]/route.def.ts" import { defineAppRoute, p } from "paramour"; export const productRoute = defineAppRoute("/product/[id]", { params: { id: p.integer() }, }); ``` `p.integer()` is a **codec**: it decodes the raw `[id]` segment into a real `number` (rejecting `"abc"` and `"1.5"` with a clear error) and serializes numbers back into the URL when you build links. Every param and search param gets one — that's how both directions stay typed. Tell the registry about the new route: ```bash npx paramour generate ``` The payoff is immediate. `href` builds URLs from the route object — params are required, typed, and serialized for you: ```ts twoslash import { defineAppRoute, href, p } from "paramour"; const productRoute = defineAppRoute("/product/[id]", { params: { id: p.integer() }, }); // ---cut--- const link = href(productRoute, { params: { id: 42 } }); // ^? ``` `href` returns a string subtype, so it drops straight into `` or `router.push(...)` — no casts, no template literals, no `[id]` left in by accident. ## Read params in the page [#read-params-in-the-page] The route object is also how the page reads its own URL. Type the page's props with `RouteProps` and parse: ```tsx twoslash title="app/product/[id]/page.tsx" // @filename: app/product/[id]/route.def.ts import { defineAppRoute, p } from "paramour"; export const productRoute = defineAppRoute("/product/[id]", { params: { id: p.integer() }, }); // @filename: app/product/[id]/page.tsx import type { RouteProps } from "paramour"; import { productRoute } from "./route.def"; export default async function ProductPage(props: RouteProps) { const { params } = await productRoute.parse(props); // ^? return

Product #{params.id}

; } ``` Visit `/product/42` and `params.id` is the number `42` — not the string `"42"`. Visit `/product/abc` and `parse` throws a structured error before your component logic runs, which your nearest `error.tsx` boundary catches. Prefer handling bad URLs yourself (a 404, say)? `safeParse` returns a discriminated result instead of throwing — the [defining-routes guide](/docs/guides/defining-routes) covers when to use which. ## Add search params [#add-search-params] Search params are declared on the same route object, with the same codecs. Extend the definition: ```ts twoslash title="app/product/[id]/route.def.ts" import { defineAppRoute, p } from "paramour"; export const productRoute = defineAppRoute("/product/[id]", { params: { id: p.integer() }, search: { page: p.integer().default(1), q: p.string().optional(), }, }); ``` The modifiers do what they say: `q` may be absent (its type gains `| undefined`), and `page` falls back to `1` when missing — so your component never handles "no page" as a special case: ```ts twoslash import { defineAppRoute, p } from "paramour"; const productRoute = defineAppRoute("/product/[id]", { params: { id: p.integer() }, search: { page: p.integer().default(1), q: p.string().optional(), }, }); declare const props: import("paramour").RouteProps; // ---cut--- const search = await productRoute.parseSearch(props); // ^? ``` Link building understands search params too — and it's deliberate about the URL it produces: ```ts twoslash import { defineAppRoute, href, p } from "paramour"; const productRoute = defineAppRoute("/product/[id]", { params: { id: p.integer() }, search: { page: p.integer().default(1), q: p.string().optional(), }, }); // ---cut--- const link = href(productRoute, { params: { id: 42 }, search: { page: 1, q: "wool socks" }, }); // => "/product/42?q=wool%20socks" ``` `page: 1` vanished from the URL: values equal to their `.default()` are elided, so there is exactly one URL for each state. Paramour treats the URL as public API — every serialization rule is explicit and documented in the [wire format](/docs/concepts/serialization). ## Read on the client [#read-on-the-client] Client components use hooks from `@paramour-js/next/app` — same route object, same types, no loading state (App Router params are synchronously available): ```tsx twoslash title="app/product/[id]/params-panel.tsx" // @filename: app/product/[id]/route.def.ts import { defineAppRoute, p } from "paramour"; export const productRoute = defineAppRoute("/product/[id]", { params: { id: p.integer() }, search: { page: p.integer().default(1), q: p.string().optional(), }, }); // @filename: app/product/[id]/params-panel.tsx "use client"; import { useRouteParams, useSearch } from "@paramour-js/next/app"; import { productRoute } from "./route.def"; export function ParamsPanel() { const params = useRouteParams(productRoute); const search = useSearch(productRoute); if (params.status === "error") { return

{params.error.message}

; } return (

Product #{params.data.id} {/* ^? */} {search.status === "success" && search.data.q && ( <> — searching “{search.data.q}” )}

); } ``` Render it from the page (`` below the `

`), then visit `/product/42?q=wool%20socks` — the server and client read the same URL through the same route object. A malformed URL surfaces as the `status: "error"` arm rather than a throw, so the component renders a fallback instead of crashing. If you'd rather let an error boundary handle it, `useRouteParamsOrThrow` exists — the [hooks guide](/docs/guides/hooks) compares the two styles. ## Catch drift in CI [#catch-drift-in-ci] You've run `paramour generate` once by hand. Two things keep the registry honest from here on: * **During development**, `withTypedRoutes` (wired by init) regenerates the registry as routes appear and disappear under `next dev`. * **In CI**, `paramour check` verifies the committed artifact matches the filesystem — it never writes, and exits non-zero on drift: ```bash npx paramour check ``` Add it in front of your build and a stale registry becomes a failed build, not a runtime surprise: ```json title="package.json" { "scripts": { "build": "paramour check && next build" } } ``` Why care? Because the registry is what makes route paths *compile-time checked*. Once `paramour-env.d.ts` exists, the route constructors only accept paths that actually exist in your app — typos included: ```ts twoslash // @errors: 2345 // @filename: paramour-env.d.ts import "paramour"; declare module "paramour" { interface ParamourRegister { appRoutes: "/" | "/product/[id]"; } } // @filename: app/product/[id]/route.def.ts import { defineAppRoute, p } from "paramour"; export const productRoute = defineAppRoute("/product/[productId]", { params: { productId: p.integer() }, }); ``` The route on disk is `/product/[id]`; the typo'd `[productId]` fails to compile. This is paramour's habit everywhere: mistakes that are traditionally found by clicking around are moved into the type checker. ## Where next [#where-next] * [Codecs & the type-state API](/docs/concepts/codecs) — the full `p.*` catalog and why illegal codec chains don't compile. * [Route objects as currency](/docs/concepts/route-objects) — the design idea the whole library hangs off. * [Defining routes](/docs/guides/defining-routes) — recipes for catch-alls, multiple params, and safe parsing. * [CLI workflows](/docs/guides/cli) — `generate`, `check`, `init`, `list`, and `doctor` in depth. * [AI agents](/docs/guides/ai-agents) — the bundled agent skill (`npx paramour skills`), the AGENTS.md snippet, and llms.txt. --- # AI Agents URL: https://paramour.dev/docs/guides/ai-agents Paramour is too new to be in any model's training data. Ask a coding agent to "make my routes type-safe with paramour" cold and you get guesswork — plausible-looking API calls that don't exist. So the library ships its own agent-facing knowledge: an [Agent Skills](https://agentskills.io)–format skill bundled inside `@paramour-js/next`, installed into your project by one command, and read by Claude Code, Cursor, Codex, and every other skills-reading tool. Because the skill lives in the npm package, its content always matches the installed API version. Upgrade the package and the skill upgrades with it — stale training data cannot recur as stale skill data. ## What the skill contains [#what-the-skill-contains] One `paramour` skill: a compact `SKILL.md` carrying the core non-negotiable rules (import from the barrels, the codec modifier type-state rules, verify with `paramour check`) plus a task router into four reference files loaded on demand: | Reference | Covers | | ------------------------- | --------------------------------------------------------------------------------------- | | `references/setup.md` | Greenfield install: config, `withTypedRoutes`, first route, generate/check wiring. | | `references/migration.md` | Converting raw `params`/`searchParams` usage — one route per pass, verified after each. | | `references/authoring.md` | Day-to-day: `p.*` builders, modifier chains, search params, links, hooks. | | `references/reference.md` | The API surface and a wire-format summary, for lookups. | This shape matches how skills-reading tools work (progressive disclosure: agents see only the name and description until the skill looks relevant, then load `SKILL.md`, then a reference file when the task calls for it), so the skill costs your agent almost no context until it's actually needed. The skill also teaches the CLI as a verification loop — `paramour list` to see routes as the library sees them, `paramour check` after every route change — so agent work is self-checking rather than fire-and-forget. A CI drift test in the paramour repo pins the skill's load-bearing facts (exports, CLI flags, wire rules) to the source they describe, so shipped skill content cannot silently rot. ## Installing it [#installing-it] **New project** — [`paramour init`](/docs/reference/next/cli/init) does it during setup: it detects agent tooling at the project root (`.agents/`, `.claude/`, `.codex/`, `.cursor/`, or a root `AGENTS.md`) and installs the skill for every detected tool (`--no-skills` opts out). **Existing project** — run the installer directly: ```bash npx paramour skills ``` ```txt paramour skills ✔ .claude/skills/paramour — installed (5 files) ✔ .cursor/skills/paramour — installed (5 files) ``` With no agent tooling detected it installs to the portable `.agents/skills/` location, which newer tool versions read directly. Commit the installed files — they diff cleanly and version with your lockfile. **After upgrading `@paramour-js/next`** — [`paramour doctor`](/docs/reference/next/cli/doctor) flags installed copies that no longer match the packaged content, and a plain `paramour skills` re-syncs them. Files you've edited locally are never overwritten without `--force`: tailoring the skill to your project is legitimate, and the manifest's content hashes tell edits apart from staleness. `paramour skills --check` is the CI form — exit `1` on missing or stale copies, never writes. Flags and exit codes: [skills reference](/docs/reference/next/cli/skills). ## The AGENTS.md section [#the-agentsmd-section] Skills need a skills-reading tool. As a lower-tech complement, `init` also appends a short marker-delimited paramour section to an existing root `AGENTS.md` (or `CLAUDE.md`) — the instructions file most agent tools inject into every session. It says what the skill is, where it lives, and names the verify commands, so even a tool that reads no skills at all learns the `generate` → `check` → commit loop. The `` / `` markers delimit the managed section; everything outside them is never touched, and re-running init reconciles the section in place. ## No skill installed: llms.txt [#no-skill-installed-llmstxt] Agents that reach for web search instead of local skills get a machine-readable version of this documentation: * [`paramour.dev/llms.txt`](https://paramour.dev/llms.txt) — an index of every docs page with descriptions and links. * [`paramour.dev/llms-full.txt`](https://paramour.dev/llms-full.txt) — the entire docs corpus as plain markdown in one fetch. Point an agent at either when it needs paramour knowledge and the skill isn't installed — pasting the `llms-full.txt` URL into a prompt is the zero-setup fallback. The bundled skill is still the better source when available: it is version-locked to your installed package, while the site documents the latest release. --- # CLI Workflows URL: https://paramour.dev/docs/guides/cli `@paramour-js/next` ships a `paramour` binary with six commands. One contract holds across all of them — exit `0` means success, `1` means "the thing you asked me to verify is not true" (drift, failed checks), `2` means usage or configuration errors — so CI scripting never has to guess. Every command takes `--help`. This guide covers workflows; the [CLI reference](/docs/reference/next/cli) documents every flag and default, one page per command. Configuration comes from `paramour.config.ts` (or `.mjs`/`.json`) at the project root; every field is optional, flags take precedence, and with no config at all the CLI infers `app/` / `src/app/` and `pages/` / `src/pages/`. ## generate [#generate] Scans the route directories and writes `paramour-env.d.ts` — the [registry artifact](/docs/concepts/registry) that narrows route constructors to real paths: ```bash npx paramour generate ``` ```txt paramour: wrote paramour-env.d.ts (3 app routes) ``` The write is deterministic and idempotent (byte-identical output is a skipped write, so timestamps don't churn). Flags: `--app-dir`, `--pages-dir`, `--out-file`, `--page-extensions`, and two modes: * `--watch` — regenerate on route-directory changes, for workflows outside `next dev`. (Inside `next dev`, the `withTypedRoutes` wrapper already keeps the artifact current — and a watcher lock prevents the two from fighting.) * `--check` — see below. Flag-by-flag details: [generate reference](/docs/reference/next/cli/generate). ## check [#check] `check` is exactly `generate --check`: re-scan, byte-compare against the committed artifact, exit `1` on drift, **never write**: ```bash npx paramour check ``` On drift it names what appeared and disappeared, so the fix is obvious: rerun `generate` and commit. A missing artifact also counts as drift — CI can't silently degrade to unchecked paths. This is the CI gate. The two standard wirings: ```json title="package.json" { "scripts": { "build": "paramour check && next build" } } ``` or `withTypedRoutes(config, { strict: true })`, which upgrades build-phase drift into a `next build` failure — this docs site does both. Details: [check reference](/docs/reference/next/cli/check). ## init [#init] Sets up a project non-interactively: scaffold `paramour.config.ts`, wrap `next.config` with `withTypedRoutes`, add the `"paramour"` package script, run the first generate, install [agent skills](#skills) for detected agent tools, and add a marker-delimited paramour section to an existing `AGENTS.md`/`CLAUDE.md`. Every step is idempotent and individually skippable: ```bash npx paramour init ``` Flags: `--dry-run` (report every step, write nothing), `--force` (overwrite an existing config with the scaffold), and `--no-config` / `--no-wrap` / `--no-script` / `--no-generate` / `--no-skills` / `--no-agents-md` to skip steps. If the `next.config` can't be transformed safely, init prints the wrap snippet for you to apply by hand and still exits `0` — a printed instruction is a successful outcome. The [Getting Started tutorial](/docs/getting-started#initialize) shows a full init run and explains each change; the [init reference](/docs/reference/next/cli/init) documents every flag. ## list [#list] Prints every filesystem route with its params/search shape: ```bash npx paramour list ``` ```txt app routes (2): / app/route.def.ts /product/[id] app/product/[id]/route.def.ts params: id: integer search: page: integer (default: 1) q: string (optional) ``` The filesystem scan decides *which* routes exist (same engine as `generate`); shapes come from your `defineAppRoute`/`definePagesRoute` call sites — list scans source for those calls and **evaluates the matching modules** to read the route objects, which is why definitions should stay [import-safe](/docs/concepts/route-objects#convention-routedefts-beside-the-page). Set `routeFiles` globs in `paramour.config.ts` to pin which modules are scanned. A filesystem route with no definition (or a definition whose path matches no file) is reported as a warning, not an error — the output doubles as a coverage report. `--json` emits the same data machine-readably. Details: [list reference](/docs/reference/next/cli/list). ## doctor [#doctor] Diagnoses the whole setup — config validity, route-directory discovery, artifact freshness, `next.config` wrapping, the installed `paramour` copy matching what `@paramour-js/next` depends on, installed agent skills, tsconfig coverage of the artifact, and route-definition discovery: ```bash npx paramour doctor ``` ```txt ✔ config: no paramour.config file — defaults in effect ✔ route directories: app/ ✔ artifact: paramour-env.d.ts is up to date ✔ next.config: next.config.ts wraps withTypedRoutes ✔ versions: paramour 0.4.0 satisfies @paramour-js/next 0.2.1's declared dependency ✔ skills: no agent tooling detected — skipped ✔ tsconfig: tsconfig.json includes paramour-env.d.ts ✔ route definitions: 3 found in 3 modules 3 of 3 filesystem routes have definitions doctor: 8 checks — 0 failed, 0 warnings ``` Warnings exit `0`; any failed check exits `1` (so doctor can run in CI too, if you want a broader gate than `check`). `--json` for tooling. When something about a setup feels off, run doctor before debugging by hand — it checks the boring causes first. Details: [doctor reference](/docs/reference/next/cli/doctor). ## skills [#skills] Installs the [Agent Skills](https://agentskills.io)–format skill bundled in `@paramour-js/next` into each detected agent tool's skills directory (`.agents/`, `.claude/`, `.codex/`, `.cursor/` — a root `AGENTS.md` file also counts as `.agents/`), so coding agents get version-accurate paramour instructions instead of guesswork: ```bash npx paramour skills ``` ```txt paramour skills ✔ .claude/skills/paramour — installed (5 files) ``` Re-runs sync safely — a manifest of content hashes means your local edits are never overwritten without `--force`, and `--check` verifies freshness in CI (exit `1` on missing/stale). After upgrading the package, `doctor` flags stale copies and a plain `paramour skills` re-syncs. The [AI agents guide](/docs/guides/ai-agents) covers the whole agent story (skill contents, AGENTS.md snippet, llms.txt); flag-by-flag details: [skills reference](/docs/reference/next/cli/skills). ## A CI recipe [#a-ci-recipe] The minimal honest pipeline: install, verify the registry, build. ```bash npx paramour check npx tsc --noEmit npx next build ``` `check` catches route drift explicitly and first, with a clear message — don't rely solely on strict-mode `withTypedRoutes` inside `next build` to find it later in the log. --- # Defining Routes URL: https://paramour.dev/docs/guides/defining-routes Recipes for declaring routes and parsing them on the server. If you haven't read [Route Objects as Currency](/docs/concepts/route-objects), the one-line version: define each route once in a `route.def.ts` beside its page, import the object everywhere. ## Static routes [#static-routes] No params, no config — but still worth defining, because a static route object gives you a registry-checked, typo-proof `href`: ```ts twoslash import { defineAppRoute, href } from "paramour"; export const aboutRoute = defineAppRoute("/about", {}); const link = href(aboutRoute); // ^? ``` When a route needs nothing, `href`'s options are omittable entirely. ## Dynamic segments [#dynamic-segments] One codec per `[segment]`, keyed by the segment name. The params config must match the path's segments exactly — a missing or misnamed key is a compile error: ```ts twoslash import { defineAppRoute, p } from "paramour"; export const orderRoute = defineAppRoute("/orders/[orderId]/items/[line]", { params: { line: p.index(), orderId: p.string(), }, }); ``` Any codec works in a param position — `p.index()` here gives 1-based URLs (`/orders/A1/items/1`) over 0-based array indices in memory. ## Catch-all segments [#catch-all-segments] A catch-all's codec describes **one segment element**; the array-ness comes from the segment kind. `[...slug]` (required) decodes to a non-empty array; `[[...slug]]` (optional) also matches the bare path and decodes it to `[]`: ```ts twoslash import { defineAppRoute, href, p } from "paramour"; export const docsRoute = defineAppRoute("/docs/[[...slug]]", { params: { slug: p.string() }, }); declare const props: import("paramour").RouteProps; const { slug } = await docsRoute.parseParams(props); // ^? const deep = href(docsRoute, { params: { slug: ["guides", "hooks"] } }); const root = href(docsRoute, { params: { slug: [] } }); // => "/docs/guides/hooks" and "/docs" ``` (This exact route serves the page you're reading.) ## Parsing on the server [#parsing-on-the-server] App routes expose two parse styles, and the choice is about who made the mistake: * **`parse`** — throws a structured error to your nearest `error.tsx`. Right when a malformed URL is exceptional and a generic error page is the correct answer. * **`safeParse`** — returns `{ status: "success" | "error" }`. Right when you want to decide — most commonly, treating a bad URL as a 404: ```tsx twoslash title="app/product/[id]/page.tsx" // @filename: app/product/[id]/route.def.ts import { defineAppRoute, p } from "paramour"; export const productRoute = defineAppRoute("/product/[id]", { params: { id: p.integer() }, search: { page: p.integer().default(1) }, }); // @filename: app/product/[id]/page.tsx import type { RouteProps } from "paramour"; import { notFound } from "next/navigation"; import { productRoute } from "./route.def"; export default async function ProductPage(props: RouteProps) { const result = await productRoute.safeParse(props); if (result.status === "error") notFound(); const { params, search } = result.data; return (

Product #{params.id}, page {search.page}

); } ``` Both styles come in halves, too: `parseParams` / `parseSearch` (and their `safe*` twins) when a call site only needs one side. `generateMetadata` is the classic case: ```tsx twoslash title="app/product/[id]/page.tsx" // @filename: app/product/[id]/route.def.ts import { defineAppRoute, p } from "paramour"; export const productRoute = defineAppRoute("/product/[id]", { params: { id: p.integer() }, }); // @filename: app/product/[id]/page.tsx import type { Metadata } from "next"; import type { RouteProps } from "paramour"; import { productRoute } from "./route.def"; export async function generateMetadata(props: RouteProps): Promise { const { id } = await productRoute.parseParams(props); return { title: `Product #${String(id)}` }; } ``` Parsing and metadata see identical decoding — there is one definition of what `/product/42` means. ## Pages Router [#pages-router] `definePagesRoute` declares routes for `pages/` with the same config shape; parsing happens through the sync, context-based `parseContext` / `safeParseContext` in `getServerSideProps` and friends. The [hooks guide](/docs/guides/hooks#pages-router) walks through the full Pages surface, client side included. ## After adding a route [#after-adding-a-route] Regenerate the registry so the new path joins the compile-time union: npm pnpm yarn bun ```bash npm run paramour ``` ```bash pnpm run paramour ``` ```bash yarn paramour ``` ```bash bun run paramour ``` During `next dev` the `withTypedRoutes` wrapper does this automatically; `paramour generate --watch` covers non-dev workflows. See [CLI workflows](/docs/guides/cli) for the drift-checking story in CI. --- # Devtools Panel URL: https://paramour.dev/docs/guides/devtools `@paramour-js/devtools` is a [TanStack Devtools](https://tanstack.com/devtools) panel that watches your routes decode in real time. Every [hook](/docs/guides/hooks) call reports what it saw — which route, the raw wire values, the decoded result, or the exact `issues[]` when a decode failed — and the panel renders it as a live, inspectable session per route. Concretely, that means: a sidebar of every route your components have observed, params and search tables showing wire string → decoded value per key (defaults and `.catch()` recoveries attributed, not silently blended in), decode errors with their issues, and editable search inputs that navigate the app to the URL you compose — handy for poking at a filter page's edge cases without hand-editing the address bar. ## Setup [#setup] npm pnpm yarn bun ```bash npm install @paramour-js/devtools @tanstack/react-devtools ``` ```bash pnpm add @paramour-js/devtools @tanstack/react-devtools ``` ```bash yarn add @paramour-js/devtools @tanstack/react-devtools ``` ```bash bun add @paramour-js/devtools @tanstack/react-devtools ``` You own the TanStack shell; paramour is a plugin in it. Mount it once, dev-conditionally: ```tsx twoslash title="app/devtools.tsx" "use client"; import { paramourDevtoolsPlugin } from "@paramour-js/devtools"; import { TanStackDevtools } from "@tanstack/react-devtools"; export function Devtools() { if (process.env.NODE_ENV === "production") return null; return ; } ``` ```tsx twoslash title="app/layout.tsx" // @filename: app/devtools.tsx "use client"; import { paramourDevtoolsPlugin } from "@paramour-js/devtools"; import { TanStackDevtools } from "@tanstack/react-devtools"; export function Devtools() { if (process.env.NODE_ENV === "production") return null; return ; } // @filename: app/layout.tsx import type { ReactNode } from "react"; import { Devtools } from "./devtools"; export default function RootLayout({ children }: { children: ReactNode }) { return ( {children} ); } ``` That's the whole integration. Any component using `useRouteParams` / `useSearch` (App or Pages) starts reporting automatically — there is nothing to register per route. `paramourDevtoolsPlugin({ defaultOpen: true })` opens the panel on load if you want it in your face during a debugging session. If you're composing a custom devtools shell, `ParamourDevtoolsPanel` is also exported directly — the plugin helper is just `{ id, name, render: }`. ## Production cost: none [#production-cost-none] Instrumentation lives behind `process.env.NODE_ENV !== "production"` checks that bundlers constant-fold, so the observation seam is erased from production bundles — the hooks compile down to exactly what they'd be without devtools installed. The `NODE_ENV` guard in your `Devtools` component does the same for the panel itself. Ship the code as-is; nothing needs unwiring. ## How observations reach the panel [#how-observations-reach-the-panel] The hooks in `@paramour-js/next` emit through a small dev-only seam, and the panel subscribes to it — the devtools package never re-implements decoding, so what you see is what your components actually got. The seam's contract is published (types-only) as `@paramour-js/next/devtools-seam` for anyone building other observers; its reference documentation lives with the [devtools package reference](/docs/reference/devtools). --- # Hooks URL: https://paramour.dev/docs/guides/hooks Client components read the URL through two hooks — `useRouteParams` and `useSearch` — that take the same route object as everything else. There are two hook modules, one per router, because the routers genuinely differ; the compiler won't let you import from the wrong one. ## App Router [#app-router] Import from `@paramour-js/next/app`. App-Router params are synchronously available on the client, so there is no loading state and results are SSR-consistent — the hooks return a two-arm `SafeResult`: ```tsx twoslash title="app/products/filter-summary.tsx" // @filename: app/products/route.def.ts import { defineAppRoute, p } from "paramour"; export const productsRoute = defineAppRoute("/products", { search: { page: p.integer().default(1), q: p.string().optional() }, }); // @filename: app/products/filter-summary.tsx "use client"; import { useSearch } from "@paramour-js/next/app"; import { productsRoute } from "./route.def"; export function FilterSummary() { const search = useSearch(productsRoute); if (search.status === "error") { return

{search.error.message}

; } return (

Page {search.data.page} {search.data.q && <> — “{search.data.q}”}

); } ``` The `status === "error"` guard is the pattern to copy: a user can put anything in the address bar, and a hand-mangled URL should render a fallback, not crash the tree. After the guard, `search.data` is fully narrowed. ### The `OrThrow` variants [#the-orthrow-variants] `useRouteParamsOrThrow` / `useSearchOrThrow` return the decoded value directly and throw the decode error in render, to your nearest client error boundary. Reach for them when the route is only reachable through links you built with `href` — then a malformed URL genuinely is exceptional, and per-component fallbacks would be noise. If the URL is something users edit or share (filters, search), prefer the `SafeResult` form. ### Selecting a slice [#selecting-a-slice] Every hook takes an optional `{ select }` that projects the decoded value, with equality checking so an unchanged selection keeps its previous reference while *other* params churn: ```tsx twoslash // @filename: app/products/route.def.ts import { defineAppRoute, p } from "paramour"; export const productsRoute = defineAppRoute("/products", { search: { page: p.integer().default(1), q: p.string().optional() }, }); // @filename: app/products/page-badge.tsx "use client"; import { useSearch } from "@paramour-js/next/app"; import { productsRoute } from "./route.def"; export function PageBadge() { const page = useSearch(productsRoute, { select: (s) => s.page }); // ^? return page.status === "success" ? p. {page.data} : null; } ``` The selector runs on the success arm only, and result equality is `Object.is` (pass `equality: "shallow"` for object selections). Under the hood the hooks are also stabilized on the *declared slice* of the URL: a change that only touches keys the route doesn't own — `?utm_source=` churn — returns the previous result by identity, so memoized children don't re-render. ## Pages Router [#pages-router] Import from `@paramour-js/next/pages`, with routes built by `definePagesRoute`. The Pages Router has a platform fact the App Router doesn't: on a statically-optimized page, `router.query` is empty until hydration completes. Instead of papering over it, the result type adds a third arm — `RouterResult` is `SafeResult` plus `{ status: "pending" }`: ```tsx twoslash title="components/product-badge.tsx" // @filename: lib/routes.ts import { definePagesRoute, p } from "paramour"; export const productRoute = definePagesRoute("/products/[id]", { params: { id: p.integer() }, }); // @filename: components/product-badge.tsx import { useRouteParams } from "@paramour-js/next/pages"; import { productRoute } from "../lib/routes"; export function ProductBadge() { const params = useRouteParams(productRoute); switch (params.status) { case "error": return

{params.error.message}

; case "pending": return

Loading…

; case "success": return

Product #{params.data.id}

; } } ``` Two deliberate differences from the App module: * **No `OrThrow` variants.** Throwing on `pending` would flash the error boundary on every statically-optimized page's first render; returning `T | undefined` would make the name a lie. The three-state union forcing the check *is* the design. * **No `"use client"` requirement** — that's an App Router concept; these hooks are ordinary Pages-bundle code. (On `getServerSideProps` pages the first render is already populated, so `pending` never surfaces there.) Server-side, Pages routes parse the data-fetching context synchronously: ```ts twoslash // @filename: lib/routes.ts import { definePagesRoute, p } from "paramour"; export const productRoute = definePagesRoute("/products/[id]", { params: { id: p.integer() }, search: { ref: p.string().optional() }, }); // @filename: pages/products/[id].tsx import type { GetServerSideProps } from "next"; import { productRoute } from "../../lib/routes"; export const getServerSideProps = (async (ctx) => { const result = productRoute.safeParseContext(ctx); if (result.status === "error") return { notFound: true }; const { params, search } = result.data; return { props: { id: params.id, ref: search.ref ?? null } }; }) satisfies GetServerSideProps; export default function Page({ id }: { id: number }) { return

Product #{id}

; } ``` ## The wrong-router mistake doesn't compile [#the-wrong-router-mistake-doesnt-compile] Each hook module is gated to its router's route brand. Handing an App hook a Pages route (or vice versa) is a type error at the call site — not a hydration mystery three components deep: ```ts twoslash // @errors: 2345 // @filename: lib/routes.ts import { definePagesRoute, p } from "paramour"; export const legacyRoute = definePagesRoute("/legacy/[id]", { params: { id: p.integer() }, }); // @filename: app/oops.tsx "use client"; import { useRouteParams } from "@paramour-js/next/app"; import { legacyRoute } from "../lib/routes"; export function Oops() { return useRouteParams(legacyRoute).status; } ``` ## Testing components that use these hooks [#testing-components-that-use-these-hooks] In a unit test there is no Next to read the URL from — but there is no need to mock `next/navigation` or `next/router` either. Wrap the component in `withParamourTesting` from `@paramour-js/next/testing` and hand it the URL state; both routers' hooks read from the same provider. Server code (`parse`/`safeParse`, server components) needs no harness at all — the [testing guide](/docs/guides/testing) starts from that distinction. ## Where next [#where-next] * [Search params guide](/docs/guides/search-params) — declaring the configs these hooks decode. * [nuqs adapter](/docs/guides/nuqs) — *writing* search state from the client, derived from the same codecs. * [Devtools panel](/docs/guides/devtools) — watch every hook decode live. * [Testing guide](/docs/guides/testing) — unit-test hook-calling components with no `next/*` mocks. --- # nuqs Adapter URL: https://paramour.dev/docs/guides/nuqs [nuqs](https://nuqs.dev) 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 pnpm yarn bun ```bash npm install @paramour-js/nuqs nuqs ``` ```bash pnpm add @paramour-js/nuqs nuqs ``` ```bash yarn add @paramour-js/nuqs nuqs ``` ```bash bun add @paramour-js/nuqs nuqs ``` ## A filter panel from a route [#a-filter-panel-from-a-route] `nuqsParsers(route)` turns a route's whole search config into an ordinary nuqs parser map: ```tsx twoslash title="app/products/filter-panel.tsx" // @filename: app/products/route.def.ts import { defineAppRoute, p } from "paramour"; export const productsRoute = defineAppRoute("/products", { search: { page: p.integer().default(1), q: p.string().optional(), tags: p.array(), }, }); // @filename: app/products/filter-panel.tsx "use client"; import { nuqsParsers } from "@paramour-js/nuqs"; import { useQueryStates } from "nuqs"; import { productsRoute } from "./route.def"; export function FilterPanel() { const [{ page, q, tags }, setFilters] = useQueryStates( nuqsParsers(productsRoute), ); return (
void setFilters({ page: 1, q: e.target.value })} value={q ?? ""} />
); } ``` 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](/docs/concepts/serialization) 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 [#single-codec-derivation] `nuqsParser(codec)` is the one-key form, useful when a component owns a single param: ```tsx twoslash // @filename: app/products/route.def.ts import { defineAppRoute, p } from "paramour"; export const productsRoute = defineAppRoute("/products", { search: { page: p.integer().default(1), q: p.string().optional() }, }); // @filename: app/products/search-box.tsx "use client"; import { nuqsParser } from "@paramour-js/nuqs"; import { useQueryState } from "nuqs"; import { p } from "paramour"; export function SearchBox() { const [q, setQ] = useQueryState("q", nuqsParser(p.string().optional())); return void setQ(e.target.value)} value={q ?? ""} />; } ``` ## The derivation rules [#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 | | `p.array(…)` | multi (repeated-key) parser, `[]` when absent | ## What refuses to compile [#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: ```ts twoslash // @errors: 2345 import { nuqsParser } from "@paramour-js/nuqs"; import { p } from "paramour"; const maybe = p.custom({ parse: (raw) => (raw === "null" ? null : raw), serialize: (value) => value ?? "null", }); nuqsParser(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. ## Server and client, one contract [#server-and-client-one-contract] The point of deriving rather than declaring: your server components [parse](/docs/guides/search-params) with the route, your client components *set* state through parsers derived from the route, and [`standardSearchSchema`](/docs/concepts/standard-schema#the-seam-runs-the-other-way-too) exports the same contract to tRPC and friends. One search config, every consumer. --- # Search Params in the App Router URL: https://paramour.dev/docs/guides/search-params 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 [#declaring-a-search-config] Each key gets a codec; modifiers express the presence policy: ```ts twoslash title="app/products/route.def.ts" import { defineAppRoute, p } from "paramour"; export const productsRoute = defineAppRoute("/products", { search: { page: p.integer().default(1), q: p.string().optional(), sort: p.enum(["price", "rating", "newest"]).catch("newest"), tags: p.csv(), }, }); ``` 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](/docs/concepts/serialization#round-tripping). * **`.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 [#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: ```tsx twoslash title="app/products/page.tsx" // @filename: app/products/route.def.ts import { defineAppRoute, p } from "paramour"; export const productsRoute = defineAppRoute("/products", { search: { page: p.integer().default(1), q: p.string().optional(), sort: p.enum(["price", "rating", "newest"]).catch("newest"), tags: p.csv(), }, }); // @filename: app/products/page.tsx import type { RouteProps } from "paramour"; import { productsRoute } from "./route.def"; export default async function ProductsPage(props: RouteProps) { const result = await productsRoute.safeParseSearch(props); if (result.status === "error") { return

Bad filter URL: {result.error.issues[0]?.message}

; } const { page, q, sort, tags } = result.data; // ^? return (

Page {page}, sorted by {sort} {q && <> — matching “{q}”} ({tags.length} 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 [#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): ```ts twoslash import { defineAppRoute, href, p } from "paramour"; const productsRoute = defineAppRoute("/products", { search: { page: p.integer().default(1), q: p.string().optional(), sort: p.enum(["price", "rating", "newest"]).catch("newest"), tags: p.csv(), }, }); // ---cut--- const link = href(productsRoute, { search: { page: 1, sort: "price", tags: ["sale"] }, hash: "results", }); // => "/products?sort=price&tags=sale#results" ``` A filter UI that renders `` per option gets canonical URLs for free — no `?page=1` cache-splitting, no manually-assembled query strings. ## List params: `p.array` vs `p.csv` [#list-params-parray-vs-pcsv] 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 [#outside-pages-route-handlers-and-middleware] Route handlers and middleware hold a `URLSearchParams`, not page props. The standalone decoders take the same route object: ```ts twoslash title="app/api/products/route.ts" // @filename: app/products/route.def.ts import { defineAppRoute, p } from "paramour"; export const productsRoute = defineAppRoute("/products", { search: { page: p.integer().default(1), q: p.string().optional() }, }); // @filename: app/api/products/route.ts import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { safeDecodeSearch } from "paramour"; import { productsRoute } from "../../products/route.def"; export function GET(request: NextRequest) { const result = safeDecodeSearch(productsRoute, request.nextUrl.searchParams); if (result.status === "error") { return NextResponse.json({ issues: result.error.issues }, { status: 400 }); } return NextResponse.json({ page: result.data.page }); } ``` 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](/docs/concepts/standard-schema#the-seam-runs-the-other-way-too) like tRPC.) ## The whole-object escape hatch [#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: ```ts twoslash import { defineAppRoute, rawSearch } from "paramour"; import { z } from "zod"; export const legacyRoute = defineAppRoute("/legacy", { search: rawSearch( z.object({ from: z.string(), to: z.string() }).refine( (r) => r.from !== r.to, { message: "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 [#on-the-client] Reading search params in client components is the [hooks guide](/docs/guides/hooks); *setting* them as client state — the filter-panel use case — is what the [nuqs adapter](/docs/guides/nuqs) is for. --- # Testing URL: https://paramour.dev/docs/guides/testing Start here: you may not need a testing helper at all. `parse`, `safeParse`, `href`, and every server component that takes `params`/`searchParams` as props are pure functions over their inputs — hand them a plain object and assert on the result: ```ts twoslash import { defineAppRoute, p } from "paramour"; const productsRoute = defineAppRoute("/products", { search: { page: p.integer().default(1), q: p.string().optional() }, }); // ---cut--- // a server-component test is a function call — no renderer, no provider const result = await productsRoute.safeParseSearch({ searchParams: { page: "7" }, }); result.status; // ^? ``` The helper below exists for the one place purity runs out: **client components that call the [hooks](/docs/guides/hooks) internally** (`useSearch`, `useRouteParams`, either router). Those hooks read the URL from Next, and in a unit test there is no Next — which is where people start hand-rolling `vi.mock("next/navigation")` stubs. `@paramour-js/next/testing` replaces that ceremony with a provider. ## Wrapping a render [#wrapping-a-render] `withParamourTesting(options)` returns a wrapper component for testing-library's `wrapper` option. Give it the URL state your component should see: ```tsx title="app/products/filter-summary.test.tsx" import { withParamourTesting } from "@paramour-js/next/testing"; import { render, screen } from "@testing-library/react"; import { expect, test } from "vitest"; import { FilterSummary } from "./filter-summary"; test("renders the page and query from the URL", () => { render(, { wrapper: withParamourTesting({ search: "?page=2&q=paramour" }), }); expect(screen.getByText(/Page 2/)).toBeDefined(); }); ``` `renderHook` works the same way, which is handy for asserting on the decoded result directly: ```tsx title="app/products/use-search.test.tsx" import { useSearch } from "@paramour-js/next/app"; import { withParamourTesting } from "@paramour-js/next/testing"; import { renderHook } from "@testing-library/react"; import { expect, test } from "vitest"; import { productsRoute } from "./route.def"; test("decodes through the route's codecs", () => { const { result } = renderHook(() => useSearch(productsRoute), { wrapper: withParamourTesting({ search: "page=7" }), }); expect(result.current.status).toBe("success"); }); ``` The options mirror what Next hands the hooks: `pathname` (default `"/"`), `search` (a string — `"?page=2"` and `"page=2"` are both accepted — or a `URLSearchParams`), and `params`, which takes **raw wire values** as Next delivers them (`{ id: "42" }`, not `{ id: 42 }`) so your test exercises the same decode a browser would. One provider feeds both routers — App and Pages components use the same wrapper, and hybrid apps need no second import. Navigations the hooks issue through `replace()` — including ones driven from the [devtools panel](/docs/guides/devtools) — land in an optional `onReplace` callback, so a plain `vi.fn()` captures them. ## Changing the URL mid-test [#changing-the-url-mid-test] The provider holds one stable adapter for its lifetime; prop changes update what the hooks read without remounting your component. So a mid-test URL change is just a rerender with new props — use `ParamourTestingProvider` directly when you need that: ```tsx title="app/products/filter-summary.pagination.test.tsx" import { ParamourTestingProvider } from "@paramour-js/next/testing"; import { render, screen } from "@testing-library/react"; import { expect, test } from "vitest"; import { FilterSummary } from "./filter-summary"; test("tracks a page change", () => { const { rerender } = render( , ); rerender( , ); expect(screen.getByText(/Page 2/)).toBeDefined(); }); ``` ## Storybook [#storybook] `ParamourTestingProvider` is exported precisely so it composes into wrappers you own — a Storybook decorator is one line: ```tsx title=".storybook/preview.tsx" import { ParamourTestingProvider } from "@paramour-js/next/testing"; export const decorators = [ (Story) => ( ), ]; ``` Per-story URL state works the same way inside a story's own `decorators`. ## The states a hand-rolled mock never models [#the-states-a-hand-rolled-mock-never-models] Two URL-reading states exist in real Next apps that almost nobody stubbing `next/navigation` by hand thinks to reproduce — and paramour's hooks deliberately handle both, so your tests can too: * **`params: null`** — outside an App-Router tree (including the initial render of every pages-router page in a hybrid app), Next's `useParams()` returns `null`, not `{}`. The app hooks degrade it to an empty source, so required params surface as ordinary "missing" issues on the error arm instead of crashing. Passing `params: null` reproduces exactly that state; only *omitting* `params` means `{}`. * **`mounted: false`** — a pages-branded component rendered where `next/router` is never mounted (under `app/`) hits the router's throw-on-unmounted behavior, which the pages hooks translate to a `ParamourError` naming the actual mistake. `mounted: false` reproduces the throw so you can pin that failure mode: ```tsx title="components/product-badge.unmounted.test.tsx" import { withParamourTesting } from "@paramour-js/next/testing"; import { render } from "@testing-library/react"; import { ParamourError } from "paramour"; import { expect, test } from "vitest"; import { ProductBadge } from "./product-badge"; test("rendering under app/ names the real mistake", () => { expect(() => render(, { wrapper: withParamourTesting({ mounted: false }) }), ).toThrow(ParamourError); }); ``` A third pages-only knob covers the [three-state `RouterResult`](/docs/reference/next/pages-hooks#routerresult): on a statically-optimized page, `router.query` is empty until hydration completes, and the hooks report `{ status: "pending" }`. `isReady: false` puts the provider in that pre-hydration state so the `pending` arm of your component is testable: ```tsx title="components/product-badge.pending.test.tsx" import { useRouteParams } from "@paramour-js/next/pages"; import { withParamourTesting } from "@paramour-js/next/testing"; import { renderHook } from "@testing-library/react"; import { expect, test } from "vitest"; import { productRoute } from "../lib/routes"; test("reports pending before hydration", () => { const { result } = renderHook(() => useRouteParams(productRoute), { wrapper: withParamourTesting({ isReady: false, params: { id: "42" } }), }); expect(result.current.status).toBe("pending"); }); ``` ## No module mocking involved [#no-module-mocking-involved] The provider overrides the hooks' framework reads through React context, not by replacing `next/*` modules. That has three practical consequences: * **Any runner works** — the examples use vitest, but there is no `vi.mock`, `jest.mock`, or bundler alias to port. Jest, node's test runner, and browser-mode runners all get the same one-liner. * **No collisions** — a component that also imports `redirect` or `notFound` from `next/navigation` keeps the real ones; nothing else in the module is touched. * **Zero production footprint** — real apps render no provider at all; the hooks fall through to real Next. ## Where next [#where-next] * [Testing reference](/docs/reference/next/testing) — the full options table, the pages-derived values, and both exports' signatures. * [Hooks guide](/docs/guides/hooks) — the components you'd be testing. * [Search params guide](/docs/guides/search-params) — the server-side parsing that needs none of this. --- # Concept Map URL: https://paramour.dev/docs/migrate/concept-map Same architecture, different vocabulary. Both libraries put a definition file next to each route, run a codegen step, build links through a typed helper, and read params through hooks. This table translates the whole surface; the sections after it expand the three mappings that aren't one-to-one renames. Code on the left is `next-typesafe-url` v6.1.0, shown as plain (uncompiled) snippets. Code on the right is live — compiled against the paramour packages on every docs build. ## The map [#the-map] | next-typesafe-url | paramour | Notes | | ----------------------------------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `routeType.ts` beside the page | `route.def.ts` beside the page | Same convention, different filename ([route objects](/docs/concepts/route-objects)) | | `const Route = {...} satisfies DynamicRoute` | `defineAppRoute("/path/[seg]", { params, search })` | The path is an argument, checked against the config at compile time | | `routeParams: z.object({...})` | `params: { seg: p.integer() }` | One codec per `[segment]` ([p.\* builders](/docs/reference/core/p-builders)) | | `searchParams: z.object({...})` | `search: { q: p.string() }` | Or `rawSearch(schema)` to keep one whole-object schema | | Object-valued search key (JSON on the wire) | `p.json(schema)` | The only codec that reproduces the old URL encoding — see [the guide](/docs/migrate/guide#step-6-decide-the-url-story-for-this-route) | | `.optional()` / `.default()` on zod schemas | `.optional()` / `.default()` / `.catch()` on codecs | Paramour defaults also *elide* from URLs ([serialization](/docs/concepts/serialization)) | | `z.string().transform((s) => new Date(s))` | `p.isoDate()` / `p.timestamp()` | Dates are first-class; the `z.date()` prohibition is gone | | `$path({ route, routeParams, searchParams })` | `href(route, { params, search })` | A route object instead of a string key; returns a branded string ([href](/docs/reference/core/href)) | | `withParamValidation(Page, Route)` | `await route.parse(props)` / `safeParse` | **No direct equivalent by design** — parsing is an explicit call, not a wrapper | | `InferPagePropsType` | `RouteProps` | Props stay one generic shape; the parse call produces the route-specific types | | `InferRoute` | `InferRouteParams` / `InferSearchOutput` | Params inference hangs off the route, search off the config | | `useRouteParams(Route.routeParams)` (`/app`) | `useRouteParams(route)` from `@paramour-js/next/app` | Takes the whole route; returns a two-arm `SafeResult` — no `isLoading` | | `useSearchParams(Route.searchParams)` (`/app`) | `useSearch(route)` from `@paramour-js/next/app` | Same; `OrThrow` variants throw to your error boundary ([App hooks](/docs/reference/next/app-hooks)) | | `useRouteParams` / `useSearchParams` (`/pages`) | Same names, from `@paramour-js/next/pages` | Three-arm result; `pending` replaces `isLoading` ([Pages hooks](/docs/reference/next/pages-hooks)) | | `parseServerSideParams({ params, schema })` | `route.parseContext(ctx)` / `safeParseContext` | Pages server parsing moves onto the route object | | `next-typesafe-url` CLI (mandatory, `--watch`) | `paramour generate` / `withTypedRoutes` (optional) | The library works before the first generate; codegen adds path checking ([registry](/docs/concepts/registry)) | | Generated `_next-typesafe-url_.d.ts` | Generated `paramour-env.d.ts` | Both type-only; they augment different modules, so they coexist during migration | | Hand-rolled `generateStaticParams` values | `encodeStaticParams(route, values)` | No helper existed before; this one is net-new | | Raw `ZodError` on failure | `ParamourError` hierarchy with `issues[]` | Aggregated per-key issues, one shape everywhere ([errors](/docs/reference/core/errors)) | | Catch-all via `z.array(z.string())` | An *element* codec on `[...slug]` / `[[...slug]]` | The codec describes one element; arrayness comes from the segment kind | Three rows deserve honesty up front. `withParamValidation` has no replacement wrapper — migrating means adding a parse call *inside* the page, which the [guide](/docs/migrate/guide#step-5-replace-withparamvalidation-with-a-parse-call) walks through. App Router hooks have no `isLoading` arm — if your components branch on it, that branch becomes dead code (see [differences](/docs/migrate/differences#app-router-hooks-lose-isloading)). And the JSON-blob URL format survives only where you opt into `p.json` per key (see [differences](/docs/migrate/differences#the-wire-format-changes)). ## Schemas become codecs [#schemas-become-codecs] The one mapping that isn't a rename. A zod schema validates; a codec validates *and* owns the wire format — how the value is written into the URL and read back out. Your zod schemas don't disappear: they slot into codecs as refinements, because paramour accepts any [Standard Schema](/docs/concepts/standard-schema) validator. Before, with `next-typesafe-url`: ```ts title="app/product/[productID]/routeType.ts" import { type DynamicRoute } from "next-typesafe-url"; import { z } from "zod"; export const Route = { routeParams: z.object({ productID: z.number(), }), searchParams: z.object({ location: z.enum(["us", "eu"]).optional(), userInfo: z.object({ name: z.string(), age: z.number(), }), }), } satisfies DynamicRoute; export type RouteType = typeof Route; ``` After, with paramour: ```ts twoslash title="app/product/[productID]/route.def.ts" import { defineAppRoute, p } from "paramour"; import { z } from "zod"; export const productRoute = defineAppRoute("/product/[productID]", { params: { productID: p.number() }, search: { location: p.enum(["us", "eu"]).optional(), userInfo: p.json(z.object({ name: z.string(), age: z.number() })), }, }); declare const props: import("paramour").RouteProps; const { search } = await productRoute.parse(props); // ^? ``` Each codec states its own wire behavior: `p.number()` parses the segment with a strict grammar, `p.enum` writes the member string, `p.json` keeps the zod object JSON-encoded exactly like the old library did. The [codecs concept page](/docs/concepts/codecs) covers the full model. ## Validation becomes parsing [#validation-becomes-parsing] `withParamValidation` wrapped your page so validated props arrived from outside. Paramour has deliberately no wrapper: parsing is a call you make inside the component, which is also why the halves (`parseParams`, `parseSearch`) and the `safe*` twins exist — you choose the surface and the failure mode at the call site. Before: ```tsx title="app/product/[productID]/page.tsx" import { withParamValidation } from "next-typesafe-url/app/hoc"; import type { InferPagePropsType } from "next-typesafe-url"; import { Route, type RouteType } from "./routeType"; type PageProps = InferPagePropsType; async function Page({ routeParams, searchParams }: PageProps) { const { productID } = await routeParams; // ... } export default withParamValidation(Page, Route); ``` After: ```tsx twoslash title="app/product/[productID]/page.tsx" // @filename: app/product/[productID]/route.def.ts import { defineAppRoute, p } from "paramour"; export const productRoute = defineAppRoute("/product/[productID]", { params: { productID: p.number() }, }); // @filename: app/product/[productID]/page.tsx import type { RouteProps } from "paramour"; import { productRoute } from "./route.def"; export default async function Page(props: RouteProps) { const { params } = await productRoute.parse(props); return

Product {params.productID}

; } ``` The [routes reference](/docs/reference/core/routes) lists every parse surface and when to reach for each. ## Errors: ZodError becomes issues [#errors-zoderror-becomes-issues] Where `next-typesafe-url` handed you a raw `ZodError`, every paramour failure is a `ParamourError` subclass carrying a flat `issues` array — every bad key reported at once, the same shape on the server, in hooks, and in standalone decoding. Before: ```ts const { data, isLoading, isError, error } = useSearchParams(Route.searchParams); // error: z.ZodError | undefined ``` After: ```ts twoslash import { defineAppRoute, p } from "paramour"; const route = defineAppRoute("/product/[id]", { params: { id: p.integer() } }); declare const props: import("paramour").RouteProps; const result = await route.safeParse(props); if (result.status === "error") { result.error.issues; // ^? } ``` The [errors reference](/docs/reference/core/errors) documents the hierarchy. Ready to move code? The [step-by-step guide](/docs/migrate/guide) migrates a real app one route at a time. --- # Differences URL: https://paramour.dev/docs/migrate/differences This page is the migration delta only — where the two libraries genuinely behave differently, in both directions, including the things `next-typesafe-url` does that paramour doesn't. Every claim here was verified against a live migration of a `next-typesafe-url` v6.1.0 app. ## Behavioral differences [#behavioral-differences] ### The wire format changes [#the-wire-format-changes] `next-typesafe-url` JSON-encodes object- and array-valued search params; paramour's default codecs write conventional per-key params. The same state, both captured from running apps: ```txt next-typesafe-url: /product/23?userInfo=%7B%22name%22%3A%22bob%22%2C%22age%22%3A23%7D paramour (p.json): /product/23?userInfo=%7B%22name%22%3A%22bob%22%2C%22age%22%3A23%7D paramour (flat): /product/23?age=23&name=bob ``` `p.json` is the compatibility valve: per key, it reproduces the old encoding exactly, so existing bookmarked and shared URLs keep parsing. Scalar params were already conventional in `next-typesafe-url` and carry over unchanged. The [guide's step 6](/docs/migrate/guide#step-6-decide-the-url-story-for-this-route) walks the preserve-vs-modernize decision; the [wire-format spec](/docs/reference/wire-format) defines every rule paramour follows. One byte-level nit: paramour writes query keys in deterministic (alphabetical) order, so a preserved URL may reorder its params. Both libraries parse either order. ### Defaults elide from URLs [#defaults-elide-from-urls] `next-typesafe-url` serialized whatever zod resolved — a `page: z.number().default(1)` field emits `page=1` into every link. Paramour elides value-form defaults on serialize: `page: 1` never appears, whether omitted or passed explicitly. Inbound URLs that *do* carry `page=1` still parse fine — this changes the URLs you emit, not the ones you accept. If anything downstream keys on exact URLs (analytics, caches), canonical URLs change shape here. Details in [serialization](/docs/concepts/serialization). ### Parsing is strict [#parsing-is-strict] Wire grammars are anchored and explicit: `p.integer()` rejects `1.5` and `1e3`, `p.isoDate()` accepts ISO-8601 forms only. A `z.string().transform((s) => new Date(s))` field used to accept anything `new Date()` tolerated — `?since=Jan%2015%202026` parsed before and is a decode error after. That's the point of a codec, but it is a behavior change your inbound URLs may notice. ### App Router hooks lose isLoading [#app-router-hooks-lose-isloading] The old hooks returned `{ data, isLoading, isError, error }`, and `isLoading` tracked Next's internal router. Paramour's App Router hooks return a two-arm `SafeResult` — `status` is `"success"` or `"error"`, never loading: App-Router params are synchronously available on the client and results are SSR-consistent, so there is nothing to wait for. `isLoading` branches become dead code ([guide, step 7](/docs/migrate/guide#step-7-migrate-the-client-hooks)). The Pages Router is different — there the pre-ready router state is real, and the hooks keep a third arm: `status: "pending"` replaces `isLoading`, and the type system forces you to handle it ([Pages hooks](/docs/reference/next/pages-hooks)). ### Errors are structured, and failure modes are yours to pick [#errors-are-structured-and-failure-modes-are-yours-to-pick] `next-typesafe-url` hands you raw `ZodError`s, and a request failing validation inside `withParamValidation` surfaces as an unhandled server error — our test app returned a 500 for a missing required search param. Paramour throws `ParamourError` subclasses carrying `issues[]` (every bad key at once), and the `safeParse` twins let a route choose its failure mode explicitly — the canonical pattern turns a malformed URL into `notFound()` instead ([errors](/docs/reference/core/errors)). ### Codegen is optional, not load-bearing [#codegen-is-optional-not-load-bearing] The old CLI is mandatory: `$path` autocompletes from the generated route map, and without the watcher running the types lie. Paramour works with zero codegen — path literals fall back to permissive strings — and `paramour generate` / [`withTypedRoutes`](/docs/reference/next/with-typed-routes) *upgrade* paths to a filesystem-checked union via a type-only augmentation ([registry](/docs/concepts/registry)). The "forgot to start the watcher" class of type lie is gone. ## What paramour doesn't have [#what-paramour-doesnt-have] **No string-keyed path builder.** `$path({ route: "/foo/[id]", ... })` builds a link to any route from just its string. Paramour has no global equivalent by design — you import the route object and pass it to `href`. That's what makes routes tree-shakeable, but if you liked building links without imports, this is the trade. **No validation wrapper.** `withParamValidation` made validation structurally unskippable — a page couldn't render without it. Paramour's parsing is an explicit call, so nothing stops a page from reading props raw. The mitigations are convention plus tooling: `paramour list` flags filesystem routes with no route definition. **Transforming schemas are parse-only.** `next-typesafe-url` ran zod transforms freely because URLs flowed one direction. Paramour serializes too, so a schema whose output type differs from its input can refine a decode but can't round-trip back to the wire. For bidirectional conversions the tool is `p.custom` — parse and serialize, stated explicitly ([Standard Schema](/docs/concepts/standard-schema)). ## What you gain [#what-you-gain] * **Dates as first-class codecs** — `p.isoDate()` / `p.timestamp()` replace the `z.date()` prohibition and its string-transform workaround. * **Any Standard Schema validator** — zod keeps working (it's no longer a required peer); valibot and arktype work identically ([Standard Schema](/docs/concepts/standard-schema)). * **Static-generation helpers** — `encodeStaticParams` feeds `generateStaticParams` / `getStaticPaths` from the same route definition ([href reference](/docs/reference/core/href)). * **A specified, round-tripping wire format** — every encode/decode rule is public and testable ([spec](/docs/reference/wire-format)). * **Branded hrefs and checked paths** — `href` returns a `Href` string and, once generated, route paths are verified against the filesystem at compile time. * **The surrounding tooling** — a [devtools panel](/docs/guides/devtools), a [nuqs adapter](/docs/guides/nuqs) for URL-state writing, and a CLI with [`check`](/docs/reference/next/cli/check) / [`list`](/docs/reference/next/cli/list) / [`doctor`](/docs/reference/next/cli/doctor). ## Version notes [#version-notes] Verified 2026-07-18 against `next-typesafe-url` 6.1.0 (zod peer `^4.1.12`, Next peer `^13.4.12 || ^14 || ^15`) migrating to `paramour` 0.4.0 with `@paramour-js/next` 0.2.1, on Next 15.5 with zod 4.4. If you are reading this much later, spot-check the mapped API names against the versions you run. --- # Step-by-Step Guide URL: https://paramour.dev/docs/migrate/guide This guide migrates an app incrementally: both libraries stay installed, each route moves over one at a time, and `next-typesafe-url` is removed only at the end. The app builds and runs at every step — there is no broken intermediate state, and you can ship mid-migration. The steps were executed literally, in this order, against a real `next-typesafe-url` v6.1.0 app on Next 15 before being written down. As each route migrates, the URL format of its object-valued search params changes at that moment — not at the end. Step 6 is the decision point; read it before migrating any route whose search params carry objects or arrays. ## Step 1: Install paramour alongside [#step-1-install-paramour-alongside] npm pnpm yarn bun ```bash npm install paramour @paramour-js/next ``` ```bash pnpm add paramour @paramour-js/next ``` ```bash yarn add paramour @paramour-js/next ``` ```bash bun add paramour @paramour-js/next ``` There is no peer conflict: paramour does not require zod, so your existing zod install keeps serving `next-typesafe-url` and doubles as refinement schemas inside codecs. One version note: `next-typesafe-url` declares support through Next 15 while `@paramour-js/next` supports Next 15 and up — so the migration happens on Next 15, and upgrading to Next 16 becomes available the moment the old library is gone. ## Step 2: Run both codegens side by side [#step-2-run-both-codegens-side-by-side] ```bash npx paramour init ``` `init` scaffolds `paramour.config.ts`, wraps your `next.config` with [`withTypedRoutes`](/docs/reference/next/with-typed-routes), adds a `paramour` script, and runs the first generate. All of it coexists with `next-typesafe-url`: the two artifacts have different filenames (`paramour-env.d.ts` vs `_next-typesafe-url_.d.ts`) and augment different modules, so neither build step interferes with the other. ```json title="package.json (during migration)" { "scripts": { "dev": "concurrently \"next-typesafe-url -w\" \"next dev\"", "build": "next-typesafe-url && next build", "paramour": "paramour generate" } } ``` Your existing `next-typesafe-url` watcher keeps running exactly as before; `withTypedRoutes` regenerates the paramour artifact inside `next dev` and `next build`, so no second watcher is needed. The [CLI guide](/docs/guides/cli) covers the commands in depth. ## Step 3: Pick a route and translate its definition [#step-3-pick-a-route-and-translate-its-definition] Start with a leaf route — something few pages link to. The translation is mechanical: each zod schema field becomes a codec. Before: ```ts title="app/product/[productID]/routeType.ts" import { type DynamicRoute } from "next-typesafe-url"; import { z } from "zod"; export const Route = { routeParams: z.object({ productID: z.number(), }), searchParams: z.object({ location: z.enum(["us", "eu"]).optional(), userInfo: z.object({ name: z.string(), age: z.number(), }), }), } satisfies DynamicRoute; export type RouteType = typeof Route; ``` After: ```ts twoslash title="app/product/[productID]/route.def.ts" import { defineAppRoute, p } from "paramour"; import { z } from "zod"; export const productRoute = defineAppRoute("/product/[productID]", { params: { productID: p.number() }, search: { location: p.enum(["us", "eu"]).optional(), // p.json preserves the old URL format — step 6 covers the alternative userInfo: p.json(z.object({ name: z.string(), age: z.number() })), }, }); ``` The recurring translations, in one place: | zod pattern | codec | | ------------------------------------------- | ------------------------------------------- | | `z.number()` / `z.string()` / `z.boolean()` | `p.number()` / `p.string()` / `p.boolean()` | | `z.enum([...])` | `p.enum([...])` | | `.optional()` / `.default(v)` | `.optional()` / `.default(v)` | | `z.object(...)` / `z.array(...)` values | `p.json(schema)` — or flatten, see step 6 | | Date via `z.string().transform(...)` | `p.isoDate()` or `p.timestamp()` | | Catch-all `z.array(z.string())` | An element codec: `slug: p.string()` | Keep `routeType.ts` around for now — it still serves any `$path` call sites you haven't reached yet. The [concept map](/docs/migrate/concept-map) has the full vocabulary table, and the [p.\* reference](/docs/reference/core/p-builders) the full catalog. One structural note for the Pages Router: `next-typesafe-url` defined `Route` inside the page file, but every file in `pages/` is a page, so paramour route objects for Pages routes live in a shared module instead (say `lib/routes.ts`) using `definePagesRoute` — same config shape. ## Step 4: Swap the links [#step-4-swap-the-links] Before: ```ts import { $path } from "next-typesafe-url"; const link = $path({ route: "/product/[productID]", routeParams: { productID: 23 }, searchParams: { userInfo: { name: "bob", age: 23 }, location: "us" }, }); ``` After: ```ts twoslash import { defineAppRoute, href, p } from "paramour"; import { z } from "zod"; const productRoute = defineAppRoute("/product/[productID]", { params: { productID: p.number() }, search: { location: p.enum(["us", "eu"]).optional(), userInfo: p.json(z.object({ name: z.string(), age: z.number() })), }, }); // ---cut--- const link = href(productRoute, { params: { productID: 23 }, search: { location: "us", userInfo: { name: "bob", age: 23 } }, }); link; // ^? ``` Do this app-wide for the migrated route now: grep for its string key (`"/product/[productID]"`) to find every `$path` call site. A call site still on `$path` keeps producing old-format URLs for this route. Once the last one is gone, delete the route's `routeType.ts` and re-run both codegens. See the [href reference](/docs/reference/core/href) for what the branded return type buys. ## Step 5: Replace withParamValidation with a parse call [#step-5-replace-withparamvalidation-with-a-parse-call] Before: ```tsx title="app/product/[productID]/page.tsx" import { withParamValidation } from "next-typesafe-url/app/hoc"; import type { InferPagePropsType } from "next-typesafe-url"; import { Route, type RouteType } from "./routeType"; type PageProps = InferPagePropsType; async function Page({ routeParams, searchParams }: PageProps) { const { productID } = await routeParams; const { userInfo } = await searchParams; return

Product {productID}, viewed by {userInfo.name}

; } export default withParamValidation(Page, Route); ``` After — no wrapper; the page parses its own props: ```tsx twoslash title="app/product/[productID]/page.tsx" // @filename: app/product/[productID]/route.def.ts import { defineAppRoute, p } from "paramour"; import { z } from "zod"; export const productRoute = defineAppRoute("/product/[productID]", { params: { productID: p.number() }, search: { userInfo: p.json(z.object({ name: z.string(), age: z.number() })), }, }); // @filename: app/product/[productID]/page.tsx import type { RouteProps } from "paramour"; import { notFound } from "next/navigation"; import { productRoute } from "./route.def"; export default async function Page(props: RouteProps) { const result = await productRoute.safeParse(props); if (result.status === "error") notFound(); const { params, search } = result.data; return

Product {params.productID}, viewed by {search.userInfo.name}

; } ``` Where you previously caught a `ZodError`, you now branch on `result.status` — or use `parse` and let the error reach your nearest `error.tsx`. The [defining-routes guide](/docs/guides/defining-routes) compares the two, and the [routes reference](/docs/reference/core/routes) lists the halves (`parseParams` / `parseSearch`) when a layout or `generateMetadata` needs only one side. `RouteProps` passes Next 15's build-time page check as written above (its members are promise-only, exactly what the generated `.next/types` check requires); Next's own generated `PageProps<"/product/[productID]">` also works if you prefer it — either flows into `safeParse` unchanged. ## Step 6: Decide the URL story for this route [#step-6-decide-the-url-story-for-this-route] `next-typesafe-url` wrote scalar search params conventionally and JSON-encoded everything object- or array-valued. Scalars therefore carry over untouched — a route like `/search?q=hi&page=2` parses identically before and after migration. The decision is only about the JSON-blob keys, and it's per key: **Preserve** — keep `p.json(schema)` for each formerly-object key, as in step 3. Old bookmarked and shared URLs keep working; the wire stays JSON. This is the zero-risk default, and you can revisit it later. **Modernize** — flatten the object into per-key codecs. URLs become human-readable, but old links to this route stop parsing: ```txt preserve: /product/23?location=us&userInfo=%7B%22name%22%3A%22bob%22%2C%22age%22%3A23%7D modernize: /product/23?age=23&location=us&name=bob ``` If a modernized route had object-valued URLs shared in the wild, add a redirect in `middleware.ts` that rewrites the old shape before it reaches the route. A route that never leaked such URLs (internal navigation only) can modernize freely — every `href` call site is already emitting the new format. Two smaller wire changes apply either way. Paramour elides value-form defaults — `page: 1` with `.default(1)` never appears in the URL, where the old library serialized it. And paramour's grammars are strict: `p.isoDate()` accepts ISO dates only, where a `z.string().transform()` quietly accepted anything `new Date()` could chew on. Both are covered in [differences](/docs/migrate/differences), and the [wire-format spec](/docs/reference/wire-format) states every rule. ## Step 7: Migrate the client hooks [#step-7-migrate-the-client-hooks] Before: ```tsx title="app/search/search-status.tsx" "use client"; import { useSearchParams } from "next-typesafe-url/app"; import { Route } from "./routeType"; export function SearchStatus() { const { data, isLoading, isError, error } = useSearchParams(Route.searchParams); if (isLoading) return

Loading…

; if (isError) return

Bad search params: {error.message}

; return

q={data.q}

; } ``` After — the hook takes the whole route and returns a two-arm `SafeResult`: ```tsx twoslash title="app/search/search-status.tsx" // @filename: app/search/route.def.ts import { defineAppRoute, p } from "paramour"; export const searchRoute = defineAppRoute("/search", { search: { page: p.integer().default(1), q: p.string() }, }); // @filename: app/search/search-status.tsx "use client"; import { useSearch } from "@paramour-js/next/app"; import { searchRoute } from "./route.def"; export function SearchStatus() { const search = useSearch(searchRoute); if (search.status === "error") { return

{search.error.message}

; } return

q={search.data.q}

; } ``` There is no `isLoading` branch to write: App-Router params are synchronously available and SSR-consistent, so the loading arm your old components carried is dead code after migration ([why](/docs/migrate/differences#app-router-hooks-lose-isloading)). Pages Router components keep a genuine third state — `status: "pending"` replaces `isLoading` before the router is ready. The [hooks guide](/docs/guides/hooks) and [Pages hooks reference](/docs/reference/next/pages-hooks) cover both routers. If your old `next.config` carried `transpilePackages: ["next-typesafe-url"]` to work around its Next 15 build failure (`ERR_MODULE_NOT_FOUND` for `next/router` at page-data collection), drop the entry when the migration removes the package — `@paramour-js/next/pages` builds on Next 15 without any `transpilePackages` workaround. ## Step 8: Repeat per route, in this order [#step-8-repeat-per-route-in-this-order] * **Leaf routes first** — few inbound links means few `$path` call sites to chase per route. * **Object-valued search params last** — those routes need the step 6 decision; scalar-only routes migrate mechanically. * **Catch-alls translate directly** — an element codec on the segment, nothing special ([defining routes](/docs/guides/defining-routes#dynamic-segments)). During the whole migration, `paramour list` doubles as the checklist: filesystem routes without a `route.def.ts` are flagged `filesystem only (no route definition found)`, so the remaining work is always one command away. ## Step 9: Remove next-typesafe-url [#step-9-remove-next-typesafe-url] Once `paramour list` shows every route defined and no `$path` call sites remain: ```bash npm uninstall next-typesafe-url ``` Then sweep the config surface — this grep list caught every leftover in our test migration: * `_next-typesafe-url_.d.ts` — delete the generated artifact, and drop it from `tsconfig.json`'s `include`. * `package.json` — remove `next-typesafe-url` from the `dev` and `build` scripts (the `concurrently` wrapper usually goes with it; a paramour build gate is `paramour check && next build` — see [check](/docs/reference/next/cli/check)). * `next.config` — remove `next-typesafe-url` from `transpilePackages` if you had it there. * Source: grep for `$path(`, `withParamValidation`, `InferRoute`, and `next-typesafe-url/` imports — all should be gone. Finish with a health check: ```bash npx paramour doctor ``` `doctor` verifies the config, the artifact, the `withTypedRoutes` wrap, and the tsconfig include in one pass ([reference](/docs/reference/next/cli/doctor)). ## Where next [#where-next] * [Differences](/docs/migrate/differences) — the full list of behavioral deltas, including what paramour deliberately doesn't have. * [nuqs adapter](/docs/guides/nuqs) — client-side search-param *writing* (`useState`-for-the-URL), something `next-typesafe-url` never offered. * [CLI workflows](/docs/guides/cli) — `paramour check` as a CI gate so route drift fails the build. --- # Migrating from next-typesafe-url URL: https://paramour.dev/docs/migrate Coming from `next-typesafe-url`? You already know the problem space, and the architecture barely moves: one definition file per route, a codegen step, a typed path builder, hooks on both routers. What changes is the vocabulary, the validation model — codecs that serialize as well as parse, with any Standard Schema validator instead of required zod — and, for some params, the URL wire format. This section was written by migrating a real `next-typesafe-url` v6.1.0 app route by route; every step and claim in it was executed before being documented. ## What maps directly [#what-maps-directly] * **Route definition files** — `routeType.ts` becomes `route.def.ts`, zod schemas become codecs ([concept map](/docs/migrate/concept-map#the-map)). * **Path building** — `$path({ route, ... })` becomes `href(route, ...)`, with the route object replacing the string key. * **Hooks** — same names, new import, a discriminated result instead of `isLoading` / `isError` flags. * **Codegen** — one CLI swaps for another, with a difference: paramour's is optional, and both can run side by side during the migration. ## What changes on the wire [#what-changes-on-the-wire] `next-typesafe-url` JSON-encodes object- and array-valued search params into the query string. Paramour's default codecs write conventional, human-readable params instead — which means existing bookmarked or shared URLs with object-valued params stop parsing unless you preserve the old encoding per key with `p.json`. Scalar params carry over unchanged. See [the differences page](/docs/migrate/differences#the-wire-format-changes) and the [wire-format spec](/docs/reference/wire-format) before you start. ## How this section is organized [#how-this-section-is-organized] * **[Concept map](/docs/migrate/concept-map)** — every `next-typesafe-url` term and its paramour equivalent, one table. * **[Step-by-step guide](/docs/migrate/guide)** — the incremental migration: both libraries installed, one route at a time, remove the old library last. * **[Differences](/docs/migrate/differences)** — what behaves differently, what paramour deliberately doesn't have, and what you gain. A codemod that automates the mechanical parts is planned; the manual path above is complete without it. --- # @paramour-js/devtools URL: https://paramour.dev/docs/reference/devtools `@paramour-js/devtools` is a [TanStack Devtools](https://tanstack.com/devtools) panel that observes the hooks in `@paramour-js/next` as they decode routes. The public surface is deliberately tiny — the plugin-entry helper and the panel component — because the panel never re-implements decoding: it subscribes to an observation seam the hooks emit through, so what it renders is what your components actually got. For setup and a tour of what the panel shows, see the [devtools guide](/docs/guides/devtools). This page is the API reference, plus the contract of the observation seam for anyone building other observers. ## paramourDevtoolsPlugin [#paramourdevtoolsplugin] Builds the plugin entry you hand to the TanStack Devtools shell. ```ts function paramourDevtoolsPlugin( options?: ParamourDevtoolsPluginOptions, ): ParamourDevtoolsPluginEntry; interface ParamourDevtoolsPluginOptions { readonly defaultOpen?: boolean; } ``` You own the shell — install `@tanstack/react-devtools`, mount it once, dev-conditionally, and pass paramour as a plugin: ```tsx twoslash title="app/devtools.tsx" "use client"; import { paramourDevtoolsPlugin } from "@paramour-js/devtools"; import { TanStackDevtools } from "@tanstack/react-devtools"; export function Devtools() { if (process.env.NODE_ENV === "production") return null; return ; } ``` `defaultOpen: true` opens the panel on load — useful when you want it in your face during a debugging session. That's the only option; the helper just builds `{ id, name, render }` with the panel component as `render`, plus the flag. Gotchas: * **Guard the shell yourself.** The instrumentation in the hooks is erased from production bundles automatically (the emit sites sit behind `process.env.NODE_ENV !== "production"` checks that bundlers constant-fold), but the shell component is yours — the `NODE_ENV` early return above is what keeps the panel out of production. * Nothing is registered per route: any component using `useRouteParams` / `useSearch` (App or Pages router) starts reporting the moment it renders. ## ParamourDevtoolsPanel [#paramourdevtoolspanel] The panel component itself, exported for custom devtools shells. ```ts interface ParamourDevtoolsPanelProps { readonly theme?: "dark" | "light"; } ``` Session sidebar on the left; the main pane auto-follows the route(s) whose observations match the current URL (a layout and a page may both report — both render, stacked), or shows a pinned session's last-known snapshot read-only with a stale marker. ```tsx twoslash "use client"; import { ParamourDevtoolsPanel } from "@paramour-js/devtools"; export function CustomShellPanel({ theme }: { theme: "dark" | "light" }) { return ; } ``` `theme` selects the panel's token set via a `data-theme` attribute (no theme context, no remount) and defaults to `"light"`. When the panel is mounted through [`paramourDevtoolsPlugin`](#paramourdevtoolsplugin) you never pass it — the TanStack shell clones the rendered element and injects `theme` itself; the prop exists for shells that don't. ## ParamourDevtoolsPluginEntry [#paramourdevtoolspluginentry] The type [`paramourDevtoolsPlugin`](#paramourdevtoolsplugin) returns. ```ts interface ParamourDevtoolsPluginEntry { readonly defaultOpen?: boolean; readonly id: string; readonly name: string; readonly render: ReactElement; } ``` Declared *structurally* — the shell's contract is just `name`/`render`/`id`/`defaultOpen` — so this package never imports `@tanstack/react-devtools` at runtime; assignability to the real plugin type is certified by the package's type tests. ```ts twoslash import { paramourDevtoolsPlugin } from "@paramour-js/devtools"; const entry = paramourDevtoolsPlugin({ defaultOpen: true }); // ^? ``` ## The observation seam [#the-observation-seam] The hooks in `@paramour-js/next` and the panel never import each other at runtime. They meet at a dependency-free global slot: the hooks push decode observations into it, the panel subscribes and reads them out. * **Slot key**: `Symbol.for("paramour.devtools.seam")` — the realm-global symbol registry, so a second physical copy of the module (dual-package hazard, bundler duplication) mints the *same* symbol and lands on the same slot. Either side may create the slot; both are create-if-absent. * **Types-only subpath, by design**: the contract is published as `@paramour-js/next/devtools-seam`, whose exports entry ships *types only* — a runtime import fails module resolution. Panel and hooks share the global slot, not runtime imports. The contract of record is `packages/next/src/devtools-seam.ts` in the paramour repo. * **Data-only slot**: the slot has no function fields. A function property would close over whichever module copy created the slot first, letting a duplicated or version-skewed copy pin stale behavior; with plain data, every copy of the emit/attach code operates on shared state and `version` is the only skew guard needed. * **Protocol**: subscribe = `listeners.add(fn)`; unsubscribe = `listeners.delete(fn)`; replay = synchronously read `buffer`, then `add` (same JS thread, so nothing can be emitted between the read and the add). Emitters push to `buffer` — a FIFO capped at 128 entries, oldest dropped — then invoke every listener. * **Production**: every emit site sits behind `process.env.NODE_ENV !== "production"`; Next's compilers constant-fold the check and, with the package's `sideEffects: false`, drop the then-dead import entirely. The seam module's emitted JS imports nothing, which is load-bearing for that erasure. ### ParamourDevtoolsSeam [#paramourdevtoolsseam] The slot shape. ```ts interface ParamourDevtoolsSeam { readonly buffer: ParamourObservation[]; // capped FIFO; replay = read it readonly listeners: Set<(observation: ParamourObservation) => void>; readonly version: 1; } ``` `version` is bumped only when an *existing* field's semantics change; additive fields never bump it. ### ParamourObservation [#paramourobservation] One hook decode. ```ts type ParamourObservation = | ParamourParamsObservation | ParamourSearchObservation; ``` Both arms extend a common base: ```ts interface ParamourObservationBase { readonly hook: ParamourHookId; readonly navigate: ParamourNavigate; readonly pathname: string; readonly result: ParamourObservationResult; readonly route: AnyRoute; // the LIVE route object — same JS context readonly routerKind: RouterKind; } ``` * `ParamourParamsObservation` — `kind: "params"`; its `wire` is a decode-time shallow copy of the source params record. * `ParamourSearchObservation` — `kind: "search"`; its `wire` is a [`ParamourSearchWire`](#paramoursearchwire) pair list. * `pathname` is the *emitting hook's own* resolution base at decode time — `usePathname()` (App) or `asPath`'s path part (Pages), both basePath-/locale-relative — so an observer never has to reverse-engineer a configured basePath. * `route` is the live route object, not a serialized snapshot: an observer can call `describeRoute`, the route's own codecs, and `buildSearchString` on it directly. Observations are emitted on decode *change*, and re-emitted when the hook's resolution base moves under an unchanged decode (a layout surviving `/product/1?q=a` → `/product/2?q=a`), so the captured `navigate` and `pathname` never go stale while the hook is mounted. ### ParamourObservationResult [#paramourobservationresult] The decode result, pre-`select`. ```ts type ParamourObservationResult = | SafeResult | { readonly status: "pending" }; ``` Always the hook's full `SafeResult` — the error arm carries the live `ParamsDecodeError`/`SearchDecodeError` with its `issues` — never the user's `select` projection. `pending` is the Pages-router-only third state (the router before hydration). Generic-erased on purpose: observers treat `data` structurally. ### ParamourHookId [#paramourhookid] Discriminant naming which hook reported. ```ts type ParamourHookId = | "app.useRouteParams" | "app.useRouteParamsOrThrow" | "app.useSearch" | "app.useSearchOrThrow" | "pages.useRouteParams" | "pages.useSearch"; ``` ### ParamourNavigate [#paramournavigate] ```ts type ParamourNavigate = (search: string) => void; ``` Navigation capability captured from the *emitting hook's* router — an observer commits URL edits through it, so it never guesses which router is live and never imports Next. Pass only the serialized search string (`""` or `"?…"`); the hook resolves it against its own current pathname, which is what Next's `replace()` expects back. (Reading `window.location.pathname` on the observer side instead would double a configured basePath.) It uses `replace` semantics: commit-per-edit experimenting shouldn't turn the back button into a slog. ### ParamourSearchWire [#paramoursearchwire] ```ts type ParamourSearchWire = readonly (readonly [string, string])[]; ``` Decode-time `[key, value]` pairs in wire order. The pair form is deliberate: order is load-bearing for repeated keys, and pairs round-trip losslessly — a plain record could not represent `?tags=a&tags=b`. --- # @paramour-js/eslint-plugin URL: https://paramour.dev/docs/reference/eslint-plugin `@paramour-js/eslint-plugin` exists because paramour's value proposition — validated params, typed path building, explicit serialization — evaporates silently the moment someone writes `` or `router.push("/shop?page=2")`. The code compiles, the navigation works, and the route's codecs simply never run. In a codebase mid-migration this is the default failure mode, not an edge case: every pre-existing link is a raw string, and every new one written from habit is too. The plugin's one rule finds them all. ## Install [#install] ```sh pnpm add -D @paramour-js/eslint-plugin ``` Requires ESLint 9 or 10 with flat config. There is no legacy-eslintrc preset. ## Setup [#setup] Spread the recommended preset into `eslint.config.js`: ```js title="eslint.config.js" import paramour from "@paramour-js/eslint-plugin"; export default [ // ...your other config paramour.configs.recommended, ]; ``` Or wire the rule manually: ```js title="eslint.config.js" import paramour from "@paramour-js/eslint-plugin"; export default [ { plugins: { paramour }, rules: { "paramour/no-raw-hrefs": "warn", }, }, ]; ``` The preset registers the rule at `warn` on purpose: a raw string href is *working* code, and this is a nudge toward migration, not a correctness gate. Once your routes are migrated, promote it in one line: ```js rules: { "paramour/no-raw-hrefs": "error" } ``` ## no-raw-hrefs [#no-raw-hrefs] Reports string literals — and template literals with no `${}` expressions — that start with `/`, in three Next.js App Router surfaces: 1. **``** — the `href` attribute of `Link` imported from `next/link`, under any local name. Imports are tracked through scope resolution, not name matching: `import L from "next/link"` fires on ``, while a component that happens to be called `Link` but comes from anywhere else never fires. 2. **Router methods** — the first argument of `push`, `replace`, and `prefetch` on a router obtained from `next/navigation`'s `useRouter()`, in both the variable form (`const router = useRouter()`) and the destructured form (`const { push } = useRouter()`, including renames like `const { push: go } = useRouter()`). 3. **Server redirects** — arguments to `redirect` and `permanentRedirect` imported from `next/navigation`. The server-side bypass, and the one most often forgotten. ```tsx import Link from "next/link"; import { redirect, useRouter } from "next/navigation"; ; // ✗ flagged redirect("/login"); // ✗ flagged const router = useRouter(); router.push("/shop?page=2"); // ✗ flagged ; // ✓ what the rule nudges toward ``` There is no autofix and no suggestion: a correct fix requires knowing which route object to import, from where, and what params to pass — none of it mechanically derivable from the string. The report message names the `href()` alternative instead. ### What is exempt [#what-is-exempt] Anything that does not start with `/` is ignored — external URLs (`https://…`), fragments (`#…`), `mailto:`/`tel:`, relative paths, and empty strings all pass without a protocol allowlist to maintain. Protocol-relative URLs (`//cdn.example.com/x`) are external too, and are exempt despite their leading slashes. Type-only imports (`import type Link from "next/link"`) never fire — a value usage of one is already a TypeScript error. ### Options [#options] | Option | Type | Default | Description | | ------------- | ---------- | ------- | ------------------------------------------- | | `ignorePaths` | `string[]` | `[]` | Path prefixes to exempt during a migration. | `ignorePaths` is the escape hatch for incremental adoption: a project that has migrated 10 of 80 routes silences the sections it has not reached yet instead of drowning in warnings it cannot act on. ```js rules: { "paramour/no-raw-hrefs": ["warn", { ignorePaths: ["/legacy", "/admin"] }], } ``` Matching is by path segment, not raw substring: `"/legacy"` exempts `/legacy`, `/legacy/old`, `/legacy?tab=1`, and `/legacy#top` — but **not** `/legacybar`. A trailing slash on the configured prefix is ignored (`"/legacy/"` behaves like `"/legacy"`), and `"/"` exempts everything. Prefixes only — no globs. ### Deliberately out of scope (v1) [#deliberately-out-of-scope-v1] Recorded so the omissions read as decisions, not oversights: * **Dynamic strings** — `"/users/" + id` and template literals with expressions are the riskiest hrefs but also the noisiest to flag; the static surfaces prove the false-positive policy first. * **The Pages router** — `next/router`'s `useRouter` does not fire. * **The `UrlObject` href form** — `href={{ pathname: "/foo" }}`. * **Wrapper components** — `Link` re-exported through a design-system wrapper is undetectable syntactically; a `linkComponents` option is the natural future answer. * **Routers crossing boundaries** — a router instance passed as an argument or through props escapes the same-scope initializer check. The rule is purely syntactic by design: no type information, no `parserOptions.project` requirement, works in any parser setup that produces JSX nodes. --- # @paramour-js/nuqs URL: https://paramour.dev/docs/reference/nuqs `@paramour-js/nuqs` is a thin adapter that derives [nuqs](https://nuqs.dev) parsers from the codecs a route already declares, so URL state has a single source of truth: presence, defaults, catch recovery, and equality are all read off the codec, never declared a second time. The output is ordinary nuqs currency — `useQueryState(s)`, `withOptions`, `createSerializer`, `createLoader`, and the server cache all compose untouched. Internally the adapter imports from `nuqs/server`, so deriving parsers is safe in server code too. For the guided walkthrough, see the [nuqs guide](/docs/guides/nuqs). This page is the API reference. ## nuqsParser [#nuqsparser] Derives one nuqs parser from one codec. ```ts function nuqsParser( codec: C & CompatibleCodec, ): NuqsParserOf; ``` Pass the codec exactly as it sits in a route's search config — `.optional()`, `.default()`, `.catch()` already applied. The name mirrors nuqs's `parseAs*` vocabulary from the call site's perspective: it is the parser for that codec's output. ```tsx twoslash "use client"; import { nuqsParser } from "@paramour-js/nuqs"; import { useQueryState } from "nuqs"; import { p } from "paramour"; export function SearchBox() { const [q, setQ] = useQueryState("q", nuqsParser(p.string().optional())); // ^? return void setQ(e.target.value)} value={q ?? ""} />; } ``` The derivation rules, per codec shape: | Codec shape | Derived nuqs parser | | --------------------------------- | --------------------------------------------------------- | | required or `.optional()` | single parser, nullable read (`null` = absent) | | `.default(value)` | `withDefault(value)` — non-nullable read | | `.default(() => value)` (factory) | nullable read — no `withDefault` | | `.catch(fallback)` | malformed values recover to the fallback | | `p.array(…)` (arity-many) | multi (repeated-key) parser with `withDefault([])` | | `p.csv(…)` | single parser reading `E[] \| null` (one key on the wire) | Gotchas worth knowing: * **Factory defaults stay nullable on purpose.** A factory default is time-varying by declaration; freezing one value into `withDefault` would lie. Absent reads `null` — apply the factory at the read site if you want the paramour-decoded shape. * **`.catch()` parity comes first.** A malformed value recovers exactly as the server decode would; only a codec *without* catch falls back to nuqs's `null`. Anything that isn't a paramour `ParseError` — including a throwing catch factory — propagates loudly. * **Equality is wire-form equality.** The parser's `eq` compares serialized wire strings — the same judgment paramour's own default elision makes — so nuqs's `clearOnDefault` and paramour's URL elision agree by construction, for every codec kind including `p.custom`. * **Value-form defaults are snapshotted once** at derivation time. Mutating a reference-typed default (an array, say) after deriving is unsupported. * **Plain-JavaScript callers** passing something that is not a codec get a loud `ParamourError` at derivation time, mirroring the compile-time judgments below. ## nuqsParsers [#nuqsparsers] Derives a whole nuqs parser map from a route object (the common case) or a bare `SearchConfig`. ```ts function nuqsParsers( route: CompatibleRoute & R, ): NuqsParserMap; function nuqsParsers( config: CompatibleConfig & S, ): NuqsParserMap; ``` Each key gets the same per-codec derivation as [`nuqsParser`](#nuqsparser); the result is a plain object you hand to `useQueryStates`, `createSerializer`, `createLoader`, or the server cache as-is: ```tsx twoslash title="app/products/toolbar.tsx" // @filename: app/products/route.def.ts import { defineAppRoute, p } from "paramour"; export const productsRoute = defineAppRoute("/products", { search: { page: p.integer().default(1), q: p.string().optional(), tags: p.array(), }, }); // @filename: app/products/toolbar.tsx "use client"; import { nuqsParsers } from "@paramour-js/nuqs"; import { useQueryStates } from "nuqs"; import { productsRoute } from "./route.def"; export function Toolbar() { const [filters, setFilters] = useQueryStates(nuqsParsers(productsRoute)); filters.page; // ^? filters.q; // ^? filters.tags; // ^? return ( ); } ``` `page` reads non-nullable (its `.default(1)` became `withDefault`), `q` reads `string | null` (nuqs's spelling of absent), and `tags` is a repeated-key multi-parser that reads `[]` when absent — matching core's decode, where an absent arity-many key and `[]` are the same wire state. Gotchas: * **Routes without per-key codecs are rejected** — see [`NoNuqsTwin`](#nonuqstwin) below. `rawSearch` routes validate the whole search object with one schema, so there is nothing per-key to derive from; search-less routes and empty configs have no keys at all. The same judgments are loud `ParamourError`s at runtime for plain-JS callers. * **Route vs. config detection is by value shape**: only a route carries a router-kind string on its `~router` brand, so a search config with a key literally named `"~router"` (legal — wire keys are arbitrary strings) still takes the config path. * The map is built key-safely (`Object.entries` → `Object.fromEntries`), so keys like `"__proto__"` become ordinary own properties of the result. ## NoNuqsTwin [#nonuqstwin] The compile-time rejection brand for codec shapes with no faithful nuqs translation. ```ts interface NoNuqsTwin { readonly [noTwinReason]: Reason; } ``` Some shapes cannot be translated without changing their meaning, and the adapter refuses at the call site rather than silently conflating semantics. The mechanism: incompatible arguments are intersected with `NoNuqsTwin`, whose single symbol-keyed property is unforgeable (the symbol is not exported), so no runtime value inhabits the type and the call fails to compile — with the `Reason` literal surfacing in the error text. Rejected shapes: * **Codec output includes `null`.** nuqs reserves `null` to mean "absent or unparseable", so a codec whose *legitimate output* includes `null` would have its `null` indistinguishable from a parse failure on the nuqs side. * **`rawSearch` routes** — the whole search object is validated by one schema; there are no per-key codecs to derive from. * **Search-less routes and empty search configs** — no keys to derive parsers for. ```ts twoslash // @errors: 2345 import { nuqsParser } from "@paramour-js/nuqs"; import { p } from "paramour"; const maybe = p.custom({ parse: (raw) => (raw === "null" ? null : raw), serialize: (value) => value ?? "null", }); nuqsParser(maybe); ``` The null-output rejection is type-level only: a codec's output type is a phantom (`~out`), so there is no runtime probe, and a `null` that slips past the types degrades to nuqs's native null semantics. The structural rejections (non-codec values, `rawSearch`, empty configs) *do* have runtime backstops — plain-JavaScript callers get the same judgments as `ParamourError`s. ## Related types [#related-types] * **`NuqsParserMap`** — the parser map [`nuqsParsers`](#nuqsparsers) derives from a whole search config: `{ [K in keyof S]: NuqsParserOf }`, with the `readonly` modifier a route's `const`-inferred search slot carries stripped, so route-derived and bare-config-derived maps have the identical shape. * **`NuqsParserOf`** — the parser type [`nuqsParser`](#nuqsparser) derives from one codec: a multi (repeated-key) parser with `defaultValue: []` for arity-many codecs, a `withDefault` (non-nullable) single parser for value-form defaults, and a nullable single parser otherwise. A hand-typed `Codec<…, "defaulted">` whose default-elides flag was left at its `boolean` default falls to the nullable branch — the safe reading. --- # Wire-Format Spec URL: https://paramour.dev/docs/reference/wire-format This page is the public statement of paramour's wire format: every rule observable in a URL, numbered, with a stable anchor per rule (`#s3`, `#cv4`, …). Three artifacts state these rules, in authority order: 1. **Code comments** at each rule's implementation site are the internal statement of record. 2. **The conformance suite** (`packages/core/test/conformance.test.ts`) is the executable check — one test per observable behavior, citing the rule IDs it pins. 3. **This page** is the public republication. A CI test cross-checks the rule IDs published here against the IDs cited in the conformance suite, so the two cannot drift apart silently. Every example below is executed against the shipped `paramour` package when the docs are built: the snippet you read is the exact program that produced the output under it, and an example marked with ✗ *must* throw the error shown or the docs fail to build. Rule IDs are stable identifiers, not a contiguous sequence — gaps (`P2` with no published `P1`) are rules that were merged, retired, or kept internal before publication. Rules about API shape rather than wire behavior (type-state modifier chaining, builder grammar, reflection) live in the [codecs reference](/docs/reference/core/codecs), not here. Every rule on this page, live: compose a search config from real codecs, type values, and watch the exact URL — or paste a query string and watch it decode. ## Byte layer (S) [#byte-layer-s] The byte layer turns ordered key/value pairs of *decoded text* into the final `?…` string. It is hand-rolled on purpose — `URLSearchParams`'s serializer emits `+` for space; paramour never does. Serializing an empty pair set yields the empty string, never a bare `?`. An href whose search params all elide or are absent is just the path. Every key and every value is percent-encoded with `encodeURIComponent` semantics: spaces are `%20` — never `+` — and the encoding applies to keys exactly as it does to values. A paramour URL means the same thing under both of the web's `+` interpretations (see [P2](#p2)). An absent optional or defaulted param is omitted from the wire entirely — no key, no `=`. The empty string is not absence: it is a real value and emits `key=`. Paramour never writes `?flag` — an empty value still gets its `=` (`?flag=`). On the decode side the platform makes the two forms indistinguishable anyway: `?q` and `?q=` both decode to the empty string. Pairs follow the search config's declaration order; a repeated-key array's elements keep their input order. Same state in, same URL out — byte for byte. **Documented deviation:** JavaScript property enumeration puts integer-like keys (`"0"`, `"42"`) first, in ascending numeric order, regardless of declaration order — declaration order is unrecoverable for those keys, so they sort numerically before all others. Still deterministic, just not declaration-ordered. A repeated-key array param (`p.array`) that is absent or empty emits nothing. This is also why presence modifiers don't exist on array codecs — `.optional()`/`.default()` could never round-trip when absence and `[]` are indistinguishable on the wire. (Contrast `p.csv`, where `[]` has its own spelling — see [CV5](#cv5).) Text that cannot be percent-encoded — lone surrogates — throws a `SerializeError` at link-build time, in keys, values, and path segments alike. Never a mangled URL, never a raw `URIError`. Paramour never normalizes text. NFC and NFD spellings of the same glyph are distinct values with distinct wire forms, and each round-trips to exactly the code points you passed in. Serialization reads exactly the keys the search config declares. Anything else on the input object — extra properties a JS caller smuggles past the types — never reaches the wire. A `#fragment` comes only from `href`'s explicit `hash` option — never from a route path or a search value — and is appended *verbatim*: no encoding, the caller owns escaping. The empty string emits no `#`. ## Parse layer (P) [#parse-layer-p] The parse layer consumes *decoded values* — Next's `searchParams` object or a `URLSearchParams` — and applies each codec's grammar. The platform has already done the percent-decoding (route params are the exception; see [R5](#r5)). Whether `+` means space is the *platform decoder's* decision, made before paramour sees the value: `URLSearchParams` (and both of Next's query layers) decode `+` as a space, while an already-decoded object source keeps a literal `+`. Paramour never second-guesses either. On output this is moot by construction — paramour emits `%20` and `%2B`, never `+` ([S2](#s2)), so its URLs read identically under both interpretations. A single-value codec receiving multiple wire values is a parse failure — never silently disambiguated to the first or last occurrence. Like any parse failure, it is recoverable per-key via `.catch()` ([D2](#d2)). The decode twin of [S6](#s6): a `p.array` key with no wire occurrences decodes to the empty array, not `undefined` — array-typed params always have an array. Decoding reads (and validates) source values only for declared keys. Junk under keys paramour doesn't own — `utm_*` params, `qs`-style bracket keys, even non-string values from richer query parsers — can never fail a decode. Only `&` separates pairs. A `;` is an ordinary character inside a value, matching the modern platform (`URLSearchParams`) rather than the retired HTML4 recommendation. ## Whole-object search: the rawSearch escape hatch (SS) [#whole-object-search-the-rawsearch-escape-hatch-ss] A route's `search:` slot normally holds a codec map. `rawSearch(schema)` swaps the whole slot for a single Standard Schema that validates the entire search object at once — trading paramour's per-key machinery for schema-level control. Raw mode is entered only through the `rawSearch()` wrapper — a bare schema in the `search:` slot is not accepted, so a route never falls into the degraded whole-object mode by accident, and every use is greppable. A `search:` config is either a codec map or a `rawSearch` marker, and the two are unambiguously distinguishable at runtime (the marker's reserved `~kind` brand — codec maps never carry top-level `~` keys). `isRawSearch` is the shared public discriminant. Unlike a codec map ([P8](#p8)), a whole-object schema owns the entire source: every key reaches it, normalized to Next's own `searchParams` shape — one occurrence collapses to `string`, repeats become `string[]` — identically for both source kinds. A failing raw-search decode throws the same `SearchDecodeError` as a codec map, with each schema issue's path dot-joined into the issue `key`. A root-level issue (empty path) gets the sentinel key `""`. No serializer exists for a whole-object schema, so encoding a raw-search config is a pass-through: the caller provides already-wire-shaped strings (`string` or `string[]` per key), each array element becomes one repeated pair, and the schema is not consulted. Non-string leaves are a `SerializeError` — there is nothing to coerce with. The encode side accepts the raw wire record; the decode side returns the schema's own inferred output. The types simply state what SS3/SS5 do. ```ts twoslash import { rawSearch, type SearchOutputOf } from "paramour"; import { z } from "zod"; const raw = rawSearch(z.object({ q: z.string(), tags: z.array(z.string()) })); type Out = SearchOutputOf; // ^? ``` Per-key `.default()`/`.catch()` and library-owned round-trip encoding are deliberately unavailable in raw mode — decode and encode are independent one-way doors. A schema-side default applies on decode but can never elide on encode ([D8](#d8) does not exist here). If you need bidirectional per-key transforms, that is `p.custom`, not `rawSearch`. ## Codec grammar on the wire (D) [#codec-grammar-on-the-wire-d] The full codec-modifier API is documented in the [codecs reference](/docs/concepts/codecs); the rules below are the subset with directly observable wire behavior. `.catch()` supplies a fallback when a *present* wire value fails its grammar. Absence is presence's job (`.optional()`/`.default()`): a missing required param is still an error under `.catch()`. An absent key decodes by its presence: `required` records an issue, `.optional()` yields `undefined`, `.default()` yields the default. The decoded object always carries *every* declared key as an own property — consumers never need `in` checks. `params:` codecs are always presence-`required` — `.optional()` and `.default()` do not exist there (the path grammar already owns optionality via `[[...slug]]`). The wire consequence: a missing dynamic segment value is always an error, never defaulted away. For `[...slug]` and `[[...slug]]` the param's codec describes a *single segment element*; the array comes from the route shape. Parsing and `.catch()` recovery are therefore element-wise — each failing element recovers independently — and an absent optional catch-all normalizes to `[]`. A value-form `.default()` participates in elision: an input equal to the default (compared by serialized wire form, against the *live* default, re-serialized per encode) is omitted, so every state has exactly one canonical URL. Factory defaults (`.default(() => …)`) never elide — a time-varying factory would swallow explicit values that later decode differently. ## CSV lists (CV) [#csv-lists-cv] `p.csv` packs a list into **one** wire value. Its sibling `p.array` repeats the key instead; both are first-class ([CV7](#cv7)). A csv list is a single `key=a,b,c` pair — arity "single", so the full modifier set applies to the list ([CV5](#cv5)). The separator is the comma, not configurable. `p.csv(element)` parses and serializes each segment with the given scalar codec (strings when omitted). Elements are *unmodified* scalars: presence, default, and catch belong to the list, not its elements, and a csv cannot nest inside a csv — the illegal compositions fail at compile time and throw at construction time for plain-JS callers. The empty wire string decodes to `[]`. Any other value must be `elem ("," elem)*` with every element non-empty: `a,,b`, a trailing `a,`, and a lone `,` are parse failures (recoverable via the list's `.catch()`, per [D2](#d2)). Element order is preserved, nothing is deduplicated, and the first element failure aborts the list parse — one key, one issue. Serialization rejects any element whose serialized form is empty (an empty segment on re-parse — `[""]` is deliberately unrepresentable, since the empty wire string already means `[]`) or contains a comma (would mis-split on re-parse). There is no in-element escaping scheme, by design: everything serialize emits, parse re-reads identically, and the failure is loud at link-build time. A csv list takes every modifier an ordinary scalar takes, and absent-vs-empty is well-defined: an absent key follows presence ([S3](#s3)); a present-but-empty `?tags=` is `[]`. Under `.default([])` both roads reach `[]`, and [D8](#d8) elision makes the bare URL the canonical spelling of "no tags". `p.array` (repeated keys — the HTML-form idiom) and `p.csv` (one-key packing — the copy-paste-friendly, nuqs-compatible spelling) are both fully supported. Same in-memory value, two deliberate wire spellings. ## Typed lists and index (PP) [#typed-lists-and-index-pp] `p.array(element)` gives repeated-key lists typed elements under the same composition rules as [CV2](#cv2) — unmodified scalar elements, modifiers on the list. Repeated-key values have no separator to collide with, so no [CV4](#cv4) twin exists; a failing element fails the key, and a *list-level* `.catch()` recovers it whole. `?page=1` decodes to index `0`; index `0` encodes to `?page=1`. Wire values below `1` are parse failures (recoverable via `.catch()`, like any malformed input), and a negative in-memory index — which cannot round-trip through the 1-based wire floor — is a `SerializeError` at link-build time. ## Route segments (R) [#route-segments-r] Path building serializes each dynamic segment with its param codec, then percent-encodes at the same byte layer as search values ([S7](#s7) applies). Static segments are emitted verbatim. `[id]` consumes one value and contributes one percent-encoded path segment. `[...slug]` contributes one segment per element. A `/` *inside* an element percent-encodes to `%2F`, so it round-trips as a single element rather than splitting into two ([R5](#r5) restores it on decode). `[[...slug]]` given `[]` or nothing emits no segments — the segment and its preceding `/` vanish, leaving the base path. A *required* catch-all given `[]` is a `SerializeError`: Next has no route for it. (Decode mirror: no real URL produces a present-but-empty required catch-all, so hand-built props doing so are a decode issue.) A param that serializes to the empty string cannot form a path segment — it would produce `//` or a vanishing segment — and is a `SerializeError` at link-build time, on every surface that serializes params. Next hands the App Router's `params` prop and `useParams()` values percent-*encoded* (Next issues #48058/#64952), so — contrary to the byte-layer's usual "the platform already decoded it" stance — core percent-decodes each segment before the codec grammar runs. A malformed sequence (`%zz`) falls back to the raw string unchanged. The Pages Router surfaces are already decoded by Node's querystring layer and opt out via `{ percentDecode: false }` to avoid double-decoding. The root route builds `/`; every other href ends in a non-slash — an elided optional catch-all leaves the bare base path, never `/docs/`. ## Related pages [#related-pages] * [Serialization & the wire format](/docs/concepts/serialization) — the narrative tour of why these rules exist. * [`p.*` codec builders](/docs/reference/core/p-builders) — each codec's anchored value grammar (`p.integer` rejects `1e3`, `p.boolean` accepts exactly `true`/`false`, …). * [Search helpers](/docs/reference/core/search) — the functions that implement these rules (`encodeSearch`, `decodeSearch`, `buildSearchString`). --- # Codec & modifiers URL: https://paramour.dev/docs/reference/core/codecs Every value paramour moves across the URL boundary is described by a codec. This page documents the `Codec` interface itself, the low-level `createCodec` factory, the three chainable modifiers, and the type-state rules that make illegal modifier chains fail to compile. For the catalog of built-in builders, see [`p.*` builders](./p-builders); for the conceptual tour, see [Codecs & the type-state API](/docs/concepts/codecs). ## Codec [#codec] The interface behind every builder: `Codec`, a bidirectional wire codec whose modifier history is tracked in its type parameters. * **`Out`** — the decoded in-memory type (`number` for `p.integer()`, `Date` for `p.isoDate()`). * **`P` (presence)** — `"required"`, `"optional"`, or `"defaulted"`. Presence governs absence semantics on both sides: whether the key may be omitted when building a URL, and whether the decoded property can be `undefined`. * **`C` (caught)** — `true` once `.catch()` has been applied. * **`A` (arity)** — `"single"` (one wire value per key) or `"many"` (repeated keys; only `p.array` produces this). * **`E` (default-elides)** — `true` after a value-form `.default()`, `false` after a factory-form one, unresolved `boolean` before either. Derived surfaces (the nuqs adapter) read this literal to decide whether a defaulted key can be given a non-nullable read. The type parameters are **type-state**: modifier methods are conditionally `never`, so an illegal chain makes the method itself uncallable. Hover the chain to watch the state accumulate: ```ts twoslash import { p } from "paramour"; const page = p.integer().default(1).catch(0); // ^? ``` Properties prefixed `~` (`~kind`, `~presence`, `~parseElement`, …) are internal machinery, not public API — user code reads codecs through [`describeCodec`](./describe#describecodec) and the utility types below. `~out` in particular is a phantom property: it carries `Out` for inference and is never set at runtime. ## createCodec [#createcodec] The low-level factory the `p.*` builders themselves use. It exists mainly for advanced and internal use — **`p.custom` is the app-author path**. `createCodec({ kind?, parseElement, serializeElement, … })` returns a fresh `Codec`. The practical difference from `p.custom`: `createCodec` does *not* rebrand foreign exceptions, so your `parseElement`/`serializeElement` must throw paramour's own `ParseError`/ `SerializeError` to participate in `.catch()` recovery and per-key error aggregation. `p.custom` wraps that bookkeeping for you. ## .optional() [#optional] Marks the key as allowed to be absent. The decoded type gains `| undefined`, the key becomes omittable on the `href`/encode side, and serializing `undefined` omits the key entirely. ```ts twoslash import { decodeSearch, p } from "paramour"; const config = { q: p.string().optional() }; const search = decodeSearch(config, new URLSearchParams("")); // ^? ``` Available only on `"required"`-presence, `"single"`-arity codecs — see [type-state rules](#type-state-rules). ## .default(value | factory) [#defaultvalue--factory] Substitutes a value when the key is absent — and, in the value form, elides the key when serializing a value equal to the default. The two forms differ deliberately (they are distinguished in type-state via the `E` parameter): ```ts twoslash import { p } from "paramour"; const page = p.integer().default(1); // ^? const since = p.timestamp().default(() => new Date()); // ^? ``` * **Value form** (`.default(1)`) — absent decodes as `1`, and serializing `1` **elides** the key, so every state has exactly one canonical URL (no `?page=1` and `?` aliases). The comparison is by wire form, and the live default is re-serialized per encode — never a stale snapshot — so mutating a reference-typed default cannot desync encode from decode. The default is also serialized once at definition time, so a default the codec cannot serialize (or that fails its schema) throws a `ParamourError` when the config is *defined*, not on some later encode. * **Factory form** (`.default(() => new Date())`) — the factory runs per decode, so reference-typed defaults are isolated per call. Factory defaults **never elide**: a time-varying default could swallow an explicitly-passed value that later decodes as a different one. Array values passed to `.default()` are handed out as fresh shallow copies per call, so mutating a decoded fallback can't pollute later decodes; other reference values are returned by reference — use a factory to isolate those. ## .catch(fallback | factory) [#catchfallback--factory] Recovers a *failed parse* to a fallback value instead of failing the decode. ```ts twoslash import { decodeSearch, p } from "paramour"; const config = { view: p.enum(["grid", "list"]).catch("grid") }; const search = decodeSearch(config, new URLSearchParams("?view=nonsense")); // => { view: "grid" } ``` `.catch()` is orthogonal to presence: it recovers parse **failures**, never absence — an absent optional key is still `undefined`, an absent required key is still an error. It also only recovers data-level `ParseError`s: misconfiguration (`ParamourError`) and serialization bugs (`SerializeError`) stay loud. Like `.default()`, it accepts a value or a zero-arg factory (array values are shallow-copied per call), and its argument is typed to the codec's output: ```ts twoslash // @errors: 2345 import { p } from "paramour"; p.enum(["asc", "desc"]).catch("descending"); ``` ## Type-state rules [#type-state-rules] Contradictory chains fail to compile: applying a modifier removes it (and any modifier it excludes) from the resulting type, collapsing the method to `never`. Calling a `never`-typed method is TS error 2349: ```ts twoslash // @errors: 2349 import { p } from "paramour"; p.string().optional().optional(); p.string().optional().default("a"); p.string().default("a").optional(); p.integer().default(1).default(2); p.integer().catch(0).catch(1); ``` The rules, spelled out: * Each modifier may be applied **once**. * `.optional()` and `.default()` are mutually exclusive in either order — what would `.optional().default(1)` mean when the key is absent? * `.catch()` combines freely with either presence modifier, in any order: `p.integer().catch(0).default(1)` and `p.integer().default(1).catch(0)` are both legal. * Arity-`"many"` codecs (`p.array`) have no presence modifiers at all — absent and `[]` are the same wire state (see [`p.array`](./p-builders#parray)). Mismatched modifier arguments fail like ordinary typed calls — for `.default()`, whose value/factory split is expressed as overloads, a wrong-typed argument reports error 2769: ```ts twoslash // @errors: 2769 import { p } from "paramour"; p.integer().default("x"); ``` One subtlety of the overloads: an `Out` whose static type includes a function member can only be defaulted through the factory form — the runtime treats any function argument as a factory, so the value branch is withheld whenever the two could disagree. These rules are not types-only theater: the same illegal chains throw `ParamourError` at runtime for plain-JavaScript consumers. The type-state is a compile-time mirror of real constraints. ## Related types [#related-types] * **`AnyCodec`** — the wildcard codec type (`Codec` across all states); the constraint to use when writing generic code over codecs. * **`OutputOf`** — the decoded type of a codec: `OutputOf>` is `number`. * **`ParamCodec`** — the codecs legal in a route's `params:` config: no presence modifiers (a path segment can't be absent), `.catch()` allowed. * **`Presence`** — `"defaulted" | "optional" | "required"`. * **`PresenceOf`** — a codec's presence state as a literal type. * **`Arity`** — `"many" | "single"`. ```ts twoslash import { p } from "paramour"; import type { OutputOf, PresenceOf } from "paramour"; const page = p.integer().default(1); type Page = OutputOf; // ^? type PagePresence = PresenceOf; // ^? ``` --- # describeCodec & describeRoute URL: https://paramour.dev/docs/reference/core/describe Codecs and routes carry their configuration in `~`-prefixed runtime properties that are not public API. The describe functions are the public face of that metadata: they reflect a codec or a route into plain data that tooling can render without ever executing `parse`/`serialize`. This surface powers `paramour list` and `paramour doctor`, and it is what the devtools panel and other derived tooling build on. ## describeCodec [#describecodec] Reflects a codec into a `CodecDescription` — kind, arity, presence, enum members, default and catch state — as plain data. ```ts twoslash import { describeCodec, p } from "paramour"; const description = describeCodec(p.enum(["asc", "desc"]).default("asc")); // ^? // => { // arity: "single", // caught: false, // defaultValue: { kind: "value", wire: "asc" }, // enumMembers: ["asc", "desc"], // kind: "enum", // presence: "defaulted", // } ``` Details worth knowing: * **Optional members are absent, not `undefined`** — `enumMembers` only exists on enum descriptions, `defaultValue` only on defaulted ones — so consumers compiled under `exactOptionalPropertyTypes` can spread descriptions safely. * **Defaults reflect per their form.** A value-form `.default()` carries its wire serialization (`{ kind: "value", wire: "asc" }` — the same text elision compares against), re-serialized from the live value at describe time. A factory default reflects as `{ kind: "factory" }`: invoking the factory per description would leak time-varying values into what should be static metadata. If a value default has been mutated into something the codec can no longer serialize, the description degrades to the factory arm rather than throwing from a read-only reflection call. * **Composites nest.** `p.csv` and `p.array` descriptions carry an `element` — a nested `CodecDescription` of the per-segment/per-key scalar. The recursion is bounded by construction: the deepest legal chain is `array>`. * **`kind` is reflection-only.** It names the builder (`"integer"`, `"enum"`, …; `p.custom` uses its `label` or `"custom"`) and is never consulted by parse/serialize. ## describeRoute [#describeroute] Reflects a defined route into a `RouteDescription`: its path, router kind, params in path order, and search config. ```ts twoslash import { defineAppRoute, describeRoute, p } from "paramour"; const productRoute = defineAppRoute("/product/[id]", { params: { id: p.integer() }, search: { page: p.index().default(0) }, }); const description = describeRoute(productRoute); // ^? ``` Both router brands are accepted (`defineAppRoute` and `definePagesRoute` results) — reflection only needs the data core. The pieces: * **`params`** — one `ParamDescription` per dynamic segment, in path order. Each is a `CodecDescription` plus a `segmentKind`: `"single"` for `[id]`, `"catchall"` for `[...slug]`, `"optional-catchall"` for `[[...slug]]`. * **`search`** — a `SearchDescription` with three shapes: `{ kind: "none" }` when the route declares no search config, `{ kind: "codecs", keys }` with a `CodecDescription` per key for the ordinary codec-map form, and `{ kind: "raw" }` for the `rawSearch` escape hatch — a Standard Schema is a black box with no introspectable structure, so `raw` is all there is to say. ## formatCodecDescription [#formatcodecdescription] Renders a `CodecDescription` as a one-line label, in one of three styles. This is *the* shared walk over a description's fields — `paramour list`'s annotations, the devtools panel's shape column, and decode-issue `expected` labels all call it, so every consumer renders the same structure. ```ts twoslash import { describeCodec, formatCodecDescription, p } from "paramour"; const sort = describeCodec(p.enum(["asc", "desc"]).default("asc").catch("asc")); formatCodecDescription(sort, "compact"); // => "enum(asc|desc) =asc catch" formatCodecDescription(sort, "verbose"); // => "enum(asc, desc) (default: asc) (catch)" ``` * **`"compact"`** — terse annotations: `?` marks optional presence, `=` plus the default's wire form (or `=ƒ()` for a factory default), a bare `catch`. Examples: ```txt string integer? enum(asc|desc) =asc catch csv string[] ``` * **`"shape"`** — the bare shape label with no presence/default/catch annotations: `enum(asc|desc)`, `csv`, `string[]`. This is the form a decode error cites as an issue's [`expected`](/docs/reference/core/errors#related-types) field. * **`"verbose"`** — parenthesized annotations in fixed order (presence, default, catch): `enum(asc, desc) (optional) (default: asc) (catch)`. Composite labels wrap or pluralize: a one-key list wraps its element (`csv`), while a repeated-key list *is* its element, pluralized (`integer[]`) — the `"array"` kind never appears in a label, the `[]` carries it, so `array>` renders as `csv[]`. ## Related types [#related-types] * **`CodecDescription`** — the reflected codec: `kind`, `arity`, `presence`, `caught`, optional `defaultValue`, `element`, and `enumMembers`. * **`CodecDefaultDescription`** — the `defaultValue` union: `{ kind: "value"; wire: string }` or `{ kind: "factory" }`. * **`CodecFormatStyle`** — `"compact" | "shape" | "verbose"`, the styles accepted by `formatCodecDescription`. * **`ParamDescription`** — a `CodecDescription` plus the `segmentKind` of the dynamic segment hosting it. * **`RouteDescription`** — the reflected route: `path`, `router`, `params`, `search`. * **`SearchDescription`** — the search slot's three shapes: `"codecs"`, `"none"`, or `"raw"`. --- # Errors URL: https://paramour.dev/docs/reference/core/errors Every error paramour throws is a `ParamourError`. Foreign throws — a Standard Schema validator that throws instead of returning issues, a `.default()` factory that fails, a props promise that rejects — are caught at chokepoints and rebranded into the hierarchy, with the original attached as `cause`. So one `instanceof ParamourError` check in an error boundary catches everything the library can produce. ```txt ParamourError ├─ ParseError one wire value failed its codec grammar or schema ├─ SerializeError a value could not be serialized to the wire ├─ ParamsDecodeError aggregate params-decode failure — carries issues ├─ SearchDecodeError aggregate search-decode failure — carries issues └─ SearchSourceError a source violated the wire-shape contract — carries key ``` `instanceof` is hardened with cross-copy identity brands: each class brands its prototype with a `Symbol.for()` symbol, which resolves in the realm-global symbol registry — so a second physical copy of the module (dual-package hazard, bundler duplication) mints the *same* symbols, and `instanceof` recognizes instances across copies. A structurally identical foreign class lacks the brands entirely and never passes. Each class checks its own brand (a `ParseError` check never matches a plain `ParamourError`), and the checks are typed as predicates, so TypeScript narrows through them. ## ParamourError [#paramourerror] The base class — and the type thrown directly for *contract violations*: an invalid path literal at define time, a hand-built route missing a codec, a config or source that isn't even an object, a rebranded foreign throw. These are programming errors, so they deliberately stay loud — the `safe*` APIs never soften them into the `error` arm. ## ParseError [#parseerror] A single wire value failed its codec grammar or schema validation — `"abc"` is not an integer, `"2026-13-40"` is not a date. This is the element-level error, and it is exactly what `.catch()` recovers: inside the high-level decoders every `ParseError` is either caught into the codec's fallback or folded into the aggregate's `issues[]`, so you normally meet it as an issue `message` rather than a raw throw. It surfaces directly only on low-level paths (custom codec internals, tooling using `paramour/internal`). ## SerializeError [#serializeerror] A value could not be serialized to the wire: the wrong runtime type, a value failing its codec or schema on serialize, a required param or search key missing at encode time, a required catch-all given `[]`, a segment serializing to `""`, a custom serializer returning a non-string, or unencodable text (lone surrogates). Thrown by the whole encode surface — `href`, `buildPath`, `encodeParams`, `encodeStaticParams`, `encodeSearch`, `searchToString`, `buildSearchString`, `serializeValue` — at link-build time, in the component that introduced the bad value. ## ParamsDecodeError [#paramsdecodeerror] The aggregate failure for a whole params decode. Carries `issues`, one entry per failed key — a decode never stops at the first problem: ```ts twoslash import { decodeParams, defineAppRoute, p, ParamourError, ParamsDecodeError, } from "paramour"; const productRoute = defineAppRoute("/product/[id]", { params: { id: p.integer() }, }); try { decodeParams(productRoute, { id: "abc" }); } catch (error) { if (error instanceof ParamsDecodeError) { error.issues; // ^? } else if (error instanceof ParamourError) { // contract violation — a bug in the caller, not a bad URL throw error; } } ``` Thrown by `decodeParams`, `route.parseParams`, and the params half of `route.parse` / `route.parseContext`; returned in the `error` arm by their `safe*` twins. ## SearchDecodeError [#searchdecodeerror] The aggregate failure for a whole search decode — same shape, same `issues` property, so error rendering written for one surface works for both. Thrown by `decodeSearch`, `route.parseSearch`, the search half of `route.parse` / `route.parseContext`, and a failed `rawSearch` schema (whose root-level issues appear under the key `""`); `standardSearchSchema` converts it to spec-shaped issues instead of throwing. ## The message is the pretty printout [#the-message-is-the-pretty-printout] Both aggregate errors also carry `route` — the owning route's path pattern, or `null` when the decode ran outside a route (a bare `decodeSearch` call) — and their `message` renders the whole failure, one `✖` line per issue: ```txt Failed to decode search params for /users/[id]: ✖ page: required search param is missing (expected integer) ✖ sort: "up" is not one of: asc, desc ``` There is no separate prettify step on purpose: an unhandled decode error reaches a Next error boundary or the dev overlay as `error.message`, so the default message is the one tuned for that moment. `(expected …)` is keyed on the issue's structured `reason`: it is appended exactly where the message cannot name the expected shape itself — a `"missing"` key has no value to describe, and a `"validate"` failure carries validator prose that may name neither the value nor the grammar. Core's own `"parse"` grammar messages already quote the value and name the grammar, and a `"duplicate"` or `"shape"` message states a problem that isn't about the grammar at all, so none of those take the suffix. ## SearchSourceError [#searchsourceerror] A search source violated its wire-shape contract: the source isn't an object, or a *declared* key's value isn't a `string` / `string[]`. Carries `key` — the offending source key, or `null` when the source itself is malformed. This is distinct from a decode failure on purpose: a malformed source is the caller's bug, so it stays loud everywhere — including through `safeDecodeSearch` — with one exception: `standardSearchSchema`'s `validate()` receives genuinely untrusted input, so exactly these errors soften to spec issues at that one boundary. ## Which API throws what [#which-api-throws-what] | API | Failure | | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | | `decodeParams`, `route.parseParams` | `ParamsDecodeError` | | `decodeSearch`, `route.parseSearch` | `SearchDecodeError`; `SearchSourceError` for a contract-violating source | | `route.parse`, `route.parseContext` | `ParamsDecodeError` or `SearchDecodeError` — params decode first | | `href`, `buildPath`, `encodeParams`, `encodeStaticParams`, `encodeSearch`, `searchToString`, `buildSearchString` | `SerializeError` | | `defineAppRoute`, `definePagesRoute` | `ParamourError` on an invalid path literal | | any API given a config or source that breaks its documented shape (plain-JS callers) | base `ParamourError` | The `safe*` and `safeDecode*` variants return the two aggregate decode errors in their `error` arm instead of throwing; everything else in this table still throws through them. ## Related types [#related-types] **`RouteDecodeError`** is the union of the two aggregate errors — the type of [`SafeResult`](/docs/reference/core/routes#saferesult)'s `error` arm: ```ts type RouteDecodeError = ParamsDecodeError | SearchDecodeError; ``` **`Issue`** is one failed key in an aggregate error — the same shape on both surfaces (and in the hooks and devtools), so issue rendering is written once: ```ts interface Issue { readonly expected?: string; readonly key: string; readonly message: string; readonly reason?: IssueReason; readonly wire?: string; } type IssueReason = "duplicate" | "missing" | "parse" | "shape" | "validate"; ``` `message` is the human prose; `expected`, `reason`, and `wire` are its structured halves for custom rendering. `reason` says what *kind* of failure the issue records — `"missing"` (no wire value for a required key), `"duplicate"` (multiple values for a single-value param), `"shape"` (source shape mismatch: an array where a segment belongs, a non-string element), `"parse"` (the codec's own wire grammar rejected the value), or `"validate"` (user-supplied code rejected it: a Standard Schema validator or a custom codec's parse). Core always sets it; it is optional only so prose-only issues built outside core remain representable. `expected` is the codec's bare shape label (`integer`, `enum(asc|desc)`, `csv[]` — [`formatCodecDescription`](/docs/reference/core/describe#formatcodecdescription)'s `"shape"` style), absent when no codec owns the key (a `rawSearch` schema issue). `wire` is the offending value as the codec grammar saw it — the value-layer string after percent-decoding, not the raw URL text (search sources arrive platform-decoded; route segments are decoded by core first, so a segment `1%20x` records `wire: "1 x"`). Absent when there isn't exactly one offending value: a missing key, a non-string source value, or the duplicate-scalar rejection. --- # href & path building URL: https://paramour.dev/docs/reference/core/href `href` is the one function for turning a route plus values into a URL — fixed `path?query#hash` assembly, every value serialized through its codec. It's a standalone function rather than a route method on purpose: parse sites sit next to one route, but href sites import `{ href }` once and use it against many routes. The path helpers below it are the pieces `href` composes, public for route handlers, middleware, and static generation. ## href [#href] Builds a link for a route object. Params are required, typed, and serialized for you; search values elide when equal to their value-form `.default()`; absent optionals are omitted — one canonical URL per state, per the [wire format](/docs/concepts/serialization): ```ts twoslash import { defineAppRoute, href, p } from "paramour"; const productRoute = defineAppRoute("/product/[id]", { params: { id: p.integer() }, search: { page: p.integer().default(1), q: p.string().optional(), }, }); // ---cut--- const link = href(productRoute, { params: { id: 42 } }); // ^? const filtered = href(productRoute, { params: { id: 42 }, search: { page: 2, q: "wool socks" }, hash: "reviews", }); // => "/product/42?page=2&q=wool%20socks#reviews" ``` The options object: | Option | Type | Notes | | -------- | ------------------------ | -------------------------------------------------------------------------------------- | | `params` | the route's params input | Required when the path has a required dynamic segment; banned when it has none at all. | | `search` | the route's search input | Required only when a search key is required; omittable otherwise. | | `hash` | `string` | Appended verbatim as `#hash`; the empty string emits no `#`. | Optionality is presence-driven on every level: when neither half has a required member, the entire options argument is omittable — `href(aboutRoute)` — and a half whose input has no keys at all may not be passed even empty. Handing `href` a value of the wrong type fails to compile: ```ts twoslash // @errors: 2769 import { defineAppRoute, href, p } from "paramour"; const productRoute = defineAppRoute("/product/[id]", { params: { id: p.integer() }, }); // ---cut--- href(productRoute, { params: { id: "42" } }); ``` Runtime failures are `SerializeError` at link-build time — a value failing its codec or schema on serialize, a required param missing (from plain JS), a required catch-all given `[]`, or a segment serializing to the empty string. Errors surface in the component that introduced the bad value, not two navigations later. No encoding is applied to `hash` — the caller owns escaping, so a value that already starts with `#` yields `##…`. Fragments come only from this option: a `#` in the route path itself is rejected at define time. ### The string form [#the-string-form] A registered **static** path can stand in for the route object — same brand, same `hash` option, no route definition needed. Its argument is typed as `RegisteredStaticRoutePaths`, so once the [registry](/docs/concepts/registry) is generated, only static paths that exist on disk are accepted: ```ts twoslash // @filename: paramour-env.d.ts import "paramour"; declare module "paramour" { interface ParamourRegister { appRoutes: "/" | "/about" | "/product/[id]"; } } // @filename: nav.ts import { href } from "paramour"; const about = href("/about", { hash: "team" }); // ^? ``` The string form takes no `params` or `search` — `params` is meaningless on a static path, and a query string only ever comes from a defined route's search codecs (an untyped side door around library-owned serialization is exactly what `href` exists to prevent). Both are banned at the type level (see [`StaticHrefOptions`](#related-types)), and the runtime backstops the contract for plain-JS callers: a path string containing `[`, `]`, `?`, or `#` throws (dynamic segments need a route object; query and hash never ride in the path string), as does passing `params`/`search` alongside a string. ## Href [#href-1] ```ts type Href

= string & { [brand]: P }; ``` The branded string every `href` call returns, carrying the route's path literal as its type argument. It is assignable **to** `string` — so `next/link`, `router.push`, `redirect`, and `generateMetadata` consume it unchanged — but not **from** `string`: only `href` mints one. ```ts twoslash // @errors: 2322 import { defineAppRoute, href, p, type Href } from "paramour"; const productRoute = defineAppRoute("/product/[id]", { params: { id: p.integer() }, }); // Assignable TO string — drops straight into : const link: string = href(productRoute, { params: { id: 42 } }); // Not assignable FROM string — only href() can produce one: const forged: Href = "/product/42"; ``` The brand is applied by a compile-time cast — no runtime value exists, so it costs nothing and never leaks into serialization. It is also the substrate for planned "accept only paramour-built links" APIs, which is why it's committed to rather than incidental. ## Related types [#related-types] | Type | What it is | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `HrefArgs` | `href`'s variadic options tuple for a route: `[options?]` when neither half has a required member, `[options]` otherwise. Useful when wrapping `href`. | | `InferHrefInput` | The options object type for a route — `{ params, search?, hash? }` with each half's optionality following its input type. | | `StaticHrefOptions` | The string form's options: `hash` only. `params` and `search` are `never`-typed — banned outright, so a non-fresh options object can't smuggle them in. | ## Path helpers [#path-helpers] ### buildPath [#buildpath] Builds just the path portion — `/` plus the encoded segments joined with `/`. What `href` uses before appending the query and fragment: ```ts twoslash import { buildPath, defineAppRoute, p } from "paramour"; const docsRoute = defineAppRoute("/docs/[[...slug]]", { params: { slug: p.string() }, }); const deep = buildPath(docsRoute, { slug: ["guides", "hooks"] }); // => "/docs/guides/hooks" const root = buildPath(docsRoute, { slug: [] }); // => "/docs" ``` An elided optional catch-all contributes no segments (the segment and its preceding `/` vanish); a fully-elided path yields `"/"`. ### encodeParams [#encodeparams] Encodes a params input into ordered, already-percent-encoded URL segment strings — one entry per emitted URL segment: ```ts twoslash import { defineAppRoute, encodeParams, p } from "paramour"; const orderRoute = defineAppRoute("/orders/[orderId]/items/[...path]", { params: { orderId: p.string(), path: p.string() }, }); const segments = encodeParams(orderRoute, { orderId: "A 1", path: ["a/b", "c"], }); // => ["orders", "A%201", "items", "a%2Fb", "c"] ``` The rules, per segment kind: a static segment is emitted verbatim (never re-encoded — the path literal is already URL-shaped), a single param contributes one entry, a catch-all one entry per element, and an elided optional catch-all none. A catch-all element containing `/` becomes `%2F` and round-trips as a single element. `SerializeError` is thrown for a missing required param, a non-array catch-all value, a required catch-all given `[]` (Next has no route for it), a value serializing to `""` (which cannot form a path segment), and unencodable text such as lone surrogates. ### encodeStaticParams [#encodestaticparams] Encodes a params input into the per-param wire-value record Next's static-generation surfaces expect — App Router `generateStaticParams` entries and Pages Router `getStaticPaths` `{ params }` objects: ```ts twoslash title="app/product/[id]/page.tsx" // @filename: app/product/[id]/route.def.ts import { defineAppRoute, p } from "paramour"; export const productRoute = defineAppRoute("/product/[id]", { params: { id: p.integer() }, }); // @filename: app/product/[id]/page.tsx import { encodeStaticParams } from "paramour"; import { productRoute } from "./route.def"; export function generateStaticParams() { return [1, 2, 3].map((id) => encodeStaticParams(productRoute, { id })); // => [{ id: "1" }, { id: "2" }, { id: "3" }] } ``` Same codec serialization and validation as `encodeParams`, with two deliberate differences: static segments are skipped (Next wants only the dynamic params, keyed by name), and values are **not** percent-encoded — Next encodes static-params values itself when it materializes the concrete URLs, so pre-encoding here would double-encode. An elided optional catch-all omits its key entirely — the one spelling of "the base path" valid on both routers. The return type is `InferStaticParams`: `[id]` → `string`, `[...slug]` → `string[]`, `[[...slug]]` → `string[]` behind an optional key — assignable to `generateStaticParams`' and `getStaticPaths`' params shapes without a cast. ### decodeParams [#decodeparams] The sync twin of `route.parseParams` — decodes a params source against a route's codecs, for call sites that already hold the values (middleware, tooling, Pages static generation): ```ts twoslash import { decodeParams, defineAppRoute, p } from "paramour"; const productRoute = defineAppRoute("/product/[id]", { params: { id: p.integer() }, }); const params = decodeParams(productRoute, { id: "42" }); // ^? ``` Failures aggregate per key into a [`ParamsDecodeError`](/docs/reference/core/errors#paramsdecodeerror) — never a throw on the first problem. Shape mismatches (`[id]` given an array, a catch-all given a string, a missing required key, a non-string element) are recorded issues and are **not** `.catch()`-recoverable: a shape mismatch means the props came from a route this definition doesn't describe. Unknown source keys are never read (Next includes parent-layout params in the App Router's props), and an absent `[[...slug]]` normalizes to `[]`. `ParamsSource` is the accepted source shape — `Record`, i.e. Next's `params` prop and `useParams()` return. **`DecodeParamsOptions`** has one knob, `percentDecode` (default `true`). The App Router hands the `params` prop and `useParams()` percent-*encoded* values (Next issues #48058/#64952), so core percent-decodes each segment before the codec grammar runs — that's the default. Pages sources (`ctx.params`, `ctx.query`, `router.query`) were already decoded by Node's querystring layer; pass `{ percentDecode: false }` there to avoid a double-decode (`/product/a%2520b` arrives from Node as `"a%20b"` and must survive as-is). A malformed percent sequence falls back to the raw string rather than failing the decode. ### safeDecodeParams [#safedecodeparams] The [`SafeResult`](/docs/reference/core/routes#saferesult) twin of `decodeParams` — same decode, discriminated result instead of a throw: ```ts twoslash import { defineAppRoute, p, safeDecodeParams } from "paramour"; const productRoute = defineAppRoute("/product/[id]", { params: { id: p.integer() }, }); // ---cut--- const result = safeDecodeParams(productRoute, { id: "abc" }); if (result.status === "error") { result.error.issues; // ^? } ``` Only a `ParamsDecodeError` becomes the `error` arm; source-contract violations and foreign errors stay loud. This pair is also the supported path for `getStaticProps`, whose context `parseContext` rejects — decode `ctx.params` here (with `{ percentDecode: false }`). --- # paramour (core) URL: https://paramour.dev/docs/reference/core `paramour` is the framework-agnostic core: the `p.*` codec builders, the route-object constructors and their parse methods, `href` for typed URL building, the standalone search/path helpers, the reflection API, and the `ParamourError` hierarchy. Everything documented in this section is imported from the package barrel (`import { … } from "paramour"`), and every code block is compiled against the real package on each docs build — hover anything to see what the compiler sees. ## Pages [#pages] * [`p.*` builders](./p-builders) — every built-in codec: wire grammars, accept/reject tables, schema slots, and list arities. * [Codec & modifiers](./codecs) — the `Codec` interface, `createCodec`, `.optional()`/`.default()`/`.catch()`, and the type-state rules that make illegal chains fail to compile. * [Routes](./routes) — `defineAppRoute`, `definePagesRoute`, the parse methods, and the registry types. * [href & path building](./href) — building typed, serialized URLs from route objects, plus the path-side helpers: `buildPath`, `encodeParams`, `encodeStaticParams`, and `decodeParams`. * [Search helpers](./search) — the standalone search encode/decode surface: `decodeSearch`, `encodeSearch`, `buildSearchString`, `rawSearch`, and `standardSearchSchema`. * [describeCodec & describeRoute](./describe) — the reflection API that powers `paramour list` and `paramour doctor`. * [Errors](./errors) — the `ParamourError` hierarchy and the `Issue` shape shared by every failed decode. ## The paramour/internal entry [#the-paramourinternal-entry] The package also ships a `paramour/internal` subpath exposing `parseValue`, `foreignMessage`, and `codecShapeLabel`. It exists for derived tooling — the devtools panel, adapters — so reflection-driven consumers can share core's implementation instead of re-deriving it. It is not for app authors, it is deliberately undocumented beyond this paragraph, and it carries no stability guarantees: its exports may change or disappear in any release. --- # p.* builders URL: https://paramour.dev/docs/reference/core/p-builders The `p` object is the catalog of codec builders. Each builder returns a [codec](./codecs) — a bidirectional wire converter that both parses a URL string into a typed value and serializes that value back. Parsing is strict: each codec matches an anchored grammar from the wire-format spec, never `Number()`-style coercion, so `"4.2"` is not an integer, `" 42"` is not a number, and `"0x10"` is not sixteen. Malformed input fails with a structured `ParseError`; an unserializable value fails with a `SerializeError` at link-build time. The tables below show **value-layer** strings — what the codec sees after percent-decoding. Byte-layer encoding (spaces as `%20`, commas as `%2C`) is applied by the [search and path layers](/docs/concepts/serialization), not by the codec. Four builders take an optional [Standard Schema](/docs/concepts/standard-schema) for domain refinement (`p.string`, `p.integer`, `p.number`, `p.index`); `p.json` requires one. A schema validates in *both* directions: schema-invalid wire input is a `ParseError`, and a schema-invalid in-memory value is a `SerializeError` when you build the link — never a URL that can't round-trip. ## p.string [#pstring] The identity codec: the decoded param text, unchanged, in both directions. `p.string()` produces `Codec`. With a schema (`StandardSchemaV1`) the codec's output type becomes the schema's output type, so branded or refined string types flow through: ```ts twoslash import { decodeSearch, p } from "paramour"; import { z } from "zod"; const config = { q: p.string(z.string().min(1)) }; const search = decodeSearch(config, new URLSearchParams("?q=socks")); // ^? ``` The schema's *returned* value is what goes on the wire, so a normalizing schema (trim, lowercase) emits canonical form on serialize. Transforming schemas — where input and output types differ — are parse-only by design: their output fails input validation on the serialize side. Use `p.custom` for bidirectional transforms. ## p.integer [#pinteger] Base-10 integers, matched by the anchored grammar `-?\d+` plus a safe-integer bound check. ```ts twoslash import { decodeSearch, p } from "paramour"; const search = decodeSearch( { page: p.integer() }, new URLSearchParams("?page=42"), ); search.page; // ^? ``` | Wire value | Result | | ------------------- | --------------------------------------------- | | `42` | `42` | | `-7` | `-7` | | `007` | `7` (non-canonical; re-serializes as `7`) | | `4.2` | `ParseError` — not an integer | | `1e3` | `ParseError` — no scientific notation | | `+5`, ` 42`, `0x10` | `ParseError` — anchored grammar | | `9007199254740993` | `ParseError` — outside the safe integer range | Serialization requires a finite safe integer — anything else (a float, `NaN`, a number beyond `Number.MAX_SAFE_INTEGER`) throws `SerializeError`. The optional schema (`StandardSchemaV1`) refines the parsed integer and re-validates on serialize. ## p.number [#pnumber] Finite base-10 numbers: an integer part, an optional decimal part, and an optional exponent (`-?\d+(\.\d+)?([eE][+-]?\d+)?`). ```ts twoslash import { decodeSearch, p } from "paramour"; const search = decodeSearch( { ratio: p.number() }, new URLSearchParams("?ratio=2.5e-1"), ); search.ratio; // ^? ``` | Wire value | Result | | ----------------- | --------------------------------------- | | `3.14` | `3.14` | | `-0.5` | `-0.5` | | `1e3` | `1000` | | `.5` | `ParseError` — leading digit required | | `5.` | `ParseError` — trailing dot | | `NaN`, `Infinity` | `ParseError` — grammar, then finiteness | | ` 42`, `0x2A` | `ParseError` — anchored grammar | Serialization is `String(value)` for a finite number; `NaN` and the infinities throw `SerializeError`. The optional schema is `StandardSchemaV1`, applied on both sides. ## p.boolean [#pboolean] Exactly `"true"` or `"false"` on the wire — nothing else. ```ts twoslash import { p, searchToString } from "paramour"; const qs = searchToString({ debug: p.boolean() }, { debug: true }); // => "?debug=true" ``` | Wire value | Result | | ------------------------- | ---------------------------------------- | | `true` | `true` | | `false` | `false` | | `1`, `TRUE`, empty string | `ParseError` — not `"true"` or `"false"` | ## p.enum [#penum] A closed set of string members. The `const` type parameter keeps the literal union, so the output narrows to exactly the members you listed: ```ts twoslash import { decodeSearch, p } from "paramour"; const search = decodeSearch( { sort: p.enum(["price", "rating"]) }, new URLSearchParams("?sort=price"), ); search.sort; // ^? ``` Parsing rejects any value outside the set with a `ParseError` naming the members; serializing a non-member (possible from plain JavaScript, or via a cast) throws `SerializeError`. The member list must be a non-empty tuple of strings, and it is exposed to reflection as [`enumMembers`](./describe#describecodec). ## p.isoDate [#pisodate] Calendar dates as `YYYY-MM-DD`, decoded to a UTC-midnight `Date`. ```ts twoslash import { decodeSearch, p } from "paramour"; const search = decodeSearch( { since: p.isoDate() }, new URLSearchParams("?since=2026-07-18"), ); search.since; // ^? ``` | Wire value | Result | | ---------------------- | --------------------------------------------- | | `2026-07-18` | `Date` at `2026-07-18T00:00:00.000Z` | | `2026-02-30` | `ParseError` — not a real calendar date | | `2026-7-4` | `ParseError` — two-digit fields required | | `2026-07-18T00:00:00Z` | `ParseError` — use `p.timestamp` for instants | Dates the engine would silently normalize are rejected: `2026-02-30` fails rather than becoming March 2nd. Serialization emits the date part of `toISOString()`; an invalid `Date` throws `SerializeError`, as does a year outside 0000–9999 (where `toISOString` switches to an expanded-year form the wire grammar cannot represent — better a loud error than a URL that can never round-trip). ## p.timestamp [#ptimestamp] Full ISO-8601 UTC instants, decoded to a `Date`. Canonical output is `Date#toISOString()` — milliseconds always present — while parsing tolerates absent or 1–3-digit milliseconds. UTC (`Z`) only: offsets are rejected. ```ts twoslash import { decodeSearch, p } from "paramour"; const search = decodeSearch( { at: p.timestamp() }, new URLSearchParams("?at=2026-07-18T12:30:00Z"), ); search.at; // ^? ``` | Wire value | Result | | --------------------------- | ---------------------------------------- | | `2026-07-18T12:30:00.000Z` | `Date` (canonical form) | | `2026-07-18T12:30:00Z` | `Date` (milliseconds optional on parse) | | `2026-07-18T12:30:00+02:00` | `ParseError` — UTC `Z` only | | `2026-02-30T00:00:00Z` | `ParseError` — not a real instant | | `2026-07-18` | `ParseError` — use `p.isoDate` for dates | Like `p.isoDate`, impossible instants the engine would normalize (`Feb 30`, hour `24`) are rejected via an exact round-trip check, and serialization applies the same valid-`Date`, year-0000–9999 guards. ## p.json [#pjson] Any JSON value packed into a single param, validated by a required Standard Schema — unvalidated `JSON.parse` output would poison the types, so there is no schemaless form: ```ts twoslash import { decodeSearch, p } from "paramour"; import { z } from "zod"; const config = { filters: p.json(z.object({ maxPrice: z.number() })) }; const search = decodeSearch( config, new URLSearchParams('?filters={"maxPrice":250}'), ); search.filters; // ^? ``` Parsing is `JSON.parse` (a syntax failure is a `ParseError`) followed by schema validation. Serializing validates first, then `JSON.stringify` — values JSON cannot represent (`undefined`, functions, symbols, circular references, `BigInt`) throw `SerializeError` rather than leaking a raw `TypeError`. On the wire the JSON text is percent-encoded by the byte layer (`?filters=%7B%22maxPrice%22%3A250%7D`). ## p.csv [#pcsv] A comma-separated list packed into **one** wire value (`?tags=a,b`), arity `"single"`. Elements are strings unless you pass an element codec: ```ts twoslash import { decodeSearch, p } from "paramour"; const search = decodeSearch( { ids: p.csv(p.integer()) }, new URLSearchParams("?ids=1,2,3"), ); search.ids; // ^? ``` | Wire value | Result | | ------------ | --------------------------------- | | `a,b` | `["a", "b"]` | | empty string | `[]` | | `a,,b` | `ParseError` — empty list element | | `a,` or `,` | `ParseError` — strict grammar | The grammar is deliberately strict: the empty wire string means `[]`, and every segment must be non-empty and parse under the element codec — the first element failure aborts the list parse. Recovery, if you want it, belongs on the *list* via `.catch()`. Serialization guards the round trip: an element that serializes to the empty string, or whose serialization contains a comma, throws `SerializeError` (it would mis-split on re-parse). A consequence: `[""]` is unrepresentable — the empty wire string already means `[]`. Because the arity is `"single"`, the full modifier set applies — `p.csv().default([])`, `.optional()`, `.catch()` all work. Element codecs, however, must be plain scalars: modifiers and array codecs are rejected at compile time. ```ts twoslash // @errors: 2345 import { p } from "paramour"; p.csv(p.integer().catch(0)); ``` A *nested* csv (`p.csv(p.csv())`) is structurally indistinguishable at the type level and is instead rejected at runtime with a `ParamourError`. ## p.array [#parray] A repeated-key list (`?tags=a&tags=b`), arity `"many"`. Like `p.csv`, elements default to strings and an element codec changes the element type: ```ts twoslash import { decodeSearch, p } from "paramour"; const search = decodeSearch( { tags: p.array() }, new URLSearchParams("?tags=sale&tags=new"), ); search.tags; // ^? ``` | Wire value | Result | | ---------------- | ------------ | | `?tags=a&tags=b` | `["a", "b"]` | | key absent | `[]` | | `?tags=a` | `["a"]` | Absence and `[]` are the same wire state, so **presence modifiers do not exist** on array codecs — a `.default()` could never be observed, and `.optional()` would promise a distinction the URL can't make. The methods are typed `never`: ```ts twoslash // @errors: 2349 import { p } from "paramour"; p.array().default(["a"]); p.array().optional(); ``` `.catch()` stays legal and preserves the `"many"` arity. Element restrictions mirror `p.csv` — no modifiers, no arity-`"many"` elements: ```ts twoslash // @errors: 2345 import { p } from "paramour"; p.array(p.integer().optional()); ``` Unlike csv, a **csv element is legal** here: `p.array(p.csv(p.integer()))` means one whole comma-packed list per repeated key (`?m=1,2&m=3,4` decodes to `[[1, 2], [3, 4]]`). ## p.index [#pindex] A pagination-friendly integer that is **1-based on the wire and 0-based in memory**: `?page=1` decodes to index `0`. ```ts twoslash import { p, searchToString } from "paramour"; const qs = searchToString({ page: p.index() }, { page: 0 }); // => "?page=1" ``` | Wire value | Result | | ---------- | ------------------------------------------- | | `1` | `0` | | `10` | `9` | | `0` | `ParseError` — below the 1-based wire floor | | `-1` | `ParseError` — below the 1-based wire floor | | `1.5` | `ParseError` — integer grammar | Wire values below `1` are a `ParseError` (recoverable via `.catch()`, like any malformed input). A negative in-memory index cannot round-trip through the 1-based wire floor, so serializing one is a `SerializeError` at link-build time. The optional schema (`StandardSchemaV1`) validates the **in-memory, 0-based** value on both sides. ## p.custom [#pcustom] The escape hatch: wrap any bidirectional parse/serialize pair. `Out` is inferred from the pair: ```ts twoslash import { p } from "paramour"; const bigintCodec = p.custom({ label: "bigint", parse: (raw) => BigInt(raw), serialize: (value: bigint) => value.toString(), }); bigintCodec; // ^? ``` * **`parse: (raw: string) => Out`** — throw for malformed input. Foreign throws (like `BigInt`'s `SyntaxError` above) are rebranded to `ParseError`, so they are recoverable via `.catch()` and aggregate per-key like any other parse failure. * **`serialize: (value: Out) => string`** — foreign throws are rebranded to `SerializeError`. * **`label`** (optional) — the reflection name shown by [`describeCodec`](./describe#describecodec) and `paramour list`; defaults to `"custom"`. Any `ParamourError` thrown inside your `parse`/`serialize` — including errors from reused paramour helpers — is never downgraded: it bypasses `.catch()` recovery and per-key aggregation. `.catch()` recovers foreign parse failures only. All three modifiers work on the result, and `p.custom` codecs are legal `p.csv`/`p.array` elements as long as the usual element rules hold (an element serialization containing a comma is caught by csv's serialize guard). --- # defineAppRoute & definePagesRoute URL: https://paramour.dev/docs/reference/core/routes The route constructors take a URL-shaped path literal plus codec configs and return a plain [route object](/docs/concepts/route-objects) — the value you import everywhere you parse a URL or build one. There is one constructor per router because the routers hand you data differently: the App Router's parse surface is async and props-based, the Pages Router's is sync and context-based. The router is a declaration, not an inference — the constructor you call brands the route, and everything downstream (parse methods, hooks) is gated on that brand. ## defineAppRoute [#defineapproute] Defines an App Router route from a path literal and a config: `params` (required exactly when the path has dynamic segments, forbidden otherwise) and `search` (always optional — a codec map, or a [`rawSearch`](/docs/reference/core/search#rawsearch) schema). ```ts twoslash import { defineAppRoute, p } from "paramour"; const productRoute = defineAppRoute("/product/[id]", { params: { id: p.integer() }, search: { page: p.integer().default(1), q: p.string().optional(), }, }); declare const props: import("paramour").RouteProps; const { params, search } = await productRoute.parse(props); // ^? search.page; // ^? ``` The parse surface is three surfaces × throwing/safe, all async: | Method | Accepts | Resolves to | | ------------------------ | ------------- | --------------------------- | | `parse(props)` | `RouteProps` | `{ params, search }` | | `parseParams(props)` | `ParamsProps` | the decoded params only | | `parseSearch(props)` | `SearchProps` | the decoded search only | | `safeParse(props)` | `RouteProps` | `SafeResult` of both halves | | `safeParseParams(props)` | `ParamsProps` | `SafeResult` of the params | | `safeParseSearch(props)` | `SearchProps` | `SafeResult` of the search | Behavior notes: * Props may be plain objects or promises (Next 15/16). Both members are awaited up front, before any decode runs — a rejecting `searchParams` promise can never become an unhandled rejection. * **Params decode first.** A params grammar failure means the URL doesn't denote this route at all (morally a 404), so `parse` throws before search is decoded. * The throwing methods throw [`ParamsDecodeError` / `SearchDecodeError`](/docs/reference/core/errors); the `safe*` twins return the same errors in a [`SafeResult`](#saferesult) instead. * The half contracts are structural: layout props are assignable to `ParamsProps`, and a missing props member decodes like an empty source — required-missing issues, never a crash. A rejecting props promise is rebranded to a `ParamourError` — with one deliberate exception: errors carrying Next's string `digest` (`NEXT_REDIRECT`, the dynamic-usage sentinel) propagate unwrapped. Next rejects the `searchParams` promise itself during a `generateStaticParams` prerender; wrapping it would turn a graceful bail-to-dynamic into a failed build. ### Path-literal validation [#path-literal-validation] The literal is validated eagerly, at define time — an invalid path throws a `ParamourError` when the module first evaluates, not when the first link is built. Rejected: a path not starting with `/` (or ending with one), `?` or `#` anywhere in it (search belongs in the `search` config, fragments in `href`'s `hash` option), empty or malformed bracket segments, route-group `(folder)` and parallel-slot `@name` spellings (route paths are URL-shaped, not filesystem-shaped), duplicate param names, and a catch-all that isn't the final segment. The `params` config is checked against the literal too: every dynamic segment needs exactly one codec, keyed by segment name — a missing or misspelled key is a compile error on its own property line. A param codec describes **one segment element** (the array-ness of a catch-all comes from the segment kind), so presence modifiers are compile errors in param position. See [defining routes](/docs/guides/defining-routes) for catch-all recipes. ## definePagesRoute [#definepagesroute] Defines a Pages Router route with the same config shape. The parse surface is the sync context pair — `getServerSideProps` and `getInitialProps` hand params and query synchronously, so there is no promised-props machinery: | Method | Accepts | Returns | | ----------------------- | -------------- | --------------------------- | | `parseContext(ctx)` | `PagesContext` | `{ params, search }` | | `safeParseContext(ctx)` | `PagesContext` | `SafeResult` of both halves | ```ts twoslash // @filename: lib/routes.ts import { definePagesRoute, p } from "paramour"; export const legacyRoute = definePagesRoute("/legacy/[id]", { params: { id: p.integer() }, search: { ref: p.string().optional() }, }); // @filename: pages/legacy/[id].tsx import type { GetServerSideProps } from "next"; import { legacyRoute } from "../../lib/routes"; export const getServerSideProps = (async (ctx) => { const { params, search } = legacyRoute.parseContext(ctx); // ^? return { props: { id: params.id, ref: search.ref ?? null } }; }) satisfies GetServerSideProps; ``` How the context splits: * `ctx.params` is authoritative for path params when present. When it's absent (`getInitialProps` — `NextPageContext` has no `params`), path params are extracted from `query` by segment name, which is sound because Next's own merge gives route params precedence in `query`. * Search is always `query` minus the path-param names. * No percent-decoding is applied: pages sources were already decoded by Node's querystring layer, so decoding again would corrupt values like `a%20b`. (The App surface is the opposite — see [`decodeParams`](/docs/reference/core/href#decodeparams).) * Params decode first — the same morally-a-404 rule as the App surface. Two contract edges worth knowing: * **`getStaticProps` is out of scope by design.** Its context carries no query string, so `parseContext` rejects it loudly rather than pretending typed search exists at build time. The static-generation story is [`decodeParams` / `safeDecodeParams`](/docs/reference/core/href#decodeparams) against `ctx.params`. * **A search key can't shadow a path param.** `router.query` merges the two halves with route params winning, so a shadowed search key could never receive a value — it fails to compile instead. (App routes have no such constraint: their two sources are separate, so `?id=` on `/product/[id]` is well-defined there.) ## SafeResult [#saferesult] The status-discriminated result shape shared by the `safe*` route methods and the standalone `safeDecodeParams` / `safeDecodeSearch` helpers: ```ts type SafeResult = | { data: T; status: "success" } | { error: RouteDecodeError; status: "error" }; ``` One `status` check narrows both arms: ```ts twoslash import { defineAppRoute, p } from "paramour"; const productRoute = defineAppRoute("/product/[id]", { params: { id: p.integer() }, }); declare const props: import("paramour").RouteProps; // ---cut--- const result = await productRoute.safeParse(props); if (result.status === "error") { result.error.issues; // ^? } else { result.data.params; // ^? } ``` Only *decode* failures reach the `error` arm — its type is [`RouteDecodeError`](/docs/reference/core/errors#related-types), the union of the two aggregate decode errors. Contract violations (a malformed source handed in from plain JS, a hand-built route missing a codec, a foreign throw out of user code) still throw: a bug should stay loud, not render as a 404. ## RouterKind [#routerkind] ```ts type RouterKind = "app" | "pages"; ``` Which router a route belongs to — the value of the route's internal router brand, set by which constructor you called. The brand is what makes the wrong parse surface *absent*, not merely ill-typed: ```ts twoslash // @errors: 2339 import { defineAppRoute, definePagesRoute, p } from "paramour"; const appRoute = defineAppRoute("/product/[id]", { params: { id: p.integer() }, }); const pagesRoute = definePagesRoute("/legacy/[id]", { params: { id: p.integer() }, }); pagesRoute.parse; appRoute.parseContext; ``` The same gate runs through the hooks: `@paramour-js/next/app` hooks accept only App routes, `@paramour-js/next/pages` hooks only Pages routes. ## Route object types [#route-object-types] The interfaces and helpers behind the values the constructors return. The `~`-prefixed members you may notice on a route object are runtime-internal — not public API; `path` is the one public data member. | Type | What it is | | ------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `Route` | The router-agnostic core every route extends: the `path` literal plus the codec configs. | | `AppRoute` | `Route` plus the async props-based parse surface. | | `PagesRoute` | `Route` plus the sync context-based parse surface. | | `AnyRoute` | The bound for helpers that take any route (`R extends AnyRoute`) — what `href` and the standalone decoders accept. | | `AnyAppRoute` / `AnyPagesRoute` | The router-specific bounds — what the App and Pages hook modules accept, respectively. | | `InferRouteParams` | The decoded params object type of a route (see below). | `InferRouteParams` follows the segment kinds: `[id]` contributes `Out`, `[...slug]` and `[[...slug]]` contribute `Out[]` — and every key is present on the output (an absent optional catch-all decodes to `[]`): ```ts twoslash import { defineAppRoute, p, type InferRouteParams } from "paramour"; const docsRoute = defineAppRoute("/docs/[[...slug]]", { params: { slug: p.string() }, }); type DocsParams = InferRouteParams; // ^? ``` The input-contract types describe what the parse methods accept — all structural, with no `next/*` import, so Next's own props and contexts are assignable while core stays framework-agnostic: | Type | Shape | | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `ParamsConfig` | The `params:` config shape — one param codec per dynamic segment name in `Path`. | | `ParamsProps` | `{ params? }` — plain or promised; the contract of `parseParams`. Layout props are assignable. | | `SearchProps` | `{ searchParams? }` — plain or promised; the contract of `parseSearch`. | | `RouteProps` | Both members — the contract of `parse` / `safeParse`; Next's generated page props are assignable. | | `PagesContext` | `{ params?, query }` — the contract of `parseContext`. `query` is required (`getStaticProps` contexts, which have none, fail to compose by design). | ## Registry types [#registry-types] These types connect route paths to filesystem truth. They are populated by a `declare module "paramour"` augmentation that `paramour generate` writes (and `withTypedRoutes` keeps current during `next dev`) — **you never write them by hand**. Before the first generate, each union falls back to `string` and any well-formed path is accepted. The full story is [the generated registry](/docs/concepts/registry). The generated artifact is a pure types-only augmentation — no runtime import, nothing in your bundle: ```ts twoslash title="paramour-env.d.ts" // Generated by @paramour-js/next. Do not edit — regenerate with `paramour generate`. import "paramour"; declare module "paramour" { interface ParamourRegister { appRoutes: "/" | "/about" | "/product/[id]"; pagesRoutes: "/legacy/[id]"; } } ``` | Type | Resolves to | | --------------------------------- | --------------------------------------------------------------------------------------------------- | | `ParamourRegister` | The augmentation target itself — an empty interface codegen merges the per-router path unions into. | | `RegisteredAppRoutePaths` | The union of registered App Router paths; `string` before generation. | | `RegisteredPagesRoutePaths` | The Pages twin. | | `RegisteredStaticAppRoutePaths` | The static-only subset of the App union — any `[` marks a path dynamic. | | `RegisteredStaticPagesRoutePaths` | The Pages twin. | | `RegisteredStaticRoutePaths` | Every registered static path across both routers — the type of `href`'s string-form argument. | The static subsets are derived by syntactic filter, not emitted separately — and the filter passes `string` through, so the pre-generation fallback survives: ```ts twoslash // @filename: paramour-env.d.ts import "paramour"; declare module "paramour" { interface ParamourRegister { appRoutes: "/" | "/about" | "/product/[id]"; } } // @filename: demo.ts import type { RegisteredStaticRoutePaths } from "paramour"; type StaticPaths = RegisteredStaticRoutePaths; // ^? ``` Two subtleties: * **Per-router independence.** Each per-router union falls back to `string` independently — an empty scan for one router never erases verification for the other. For that same reason, `RegisteredStaticRoutePaths` is deliberately *not* the union of the two per-router static types (an absent side's `string` would swallow the union); it degrades to `string` only when *neither* router has generated routes. * **Reachability is not staticness.** `/docs/[[...slug]]` serves `/docs`, but it carries a codec and decode expectations, so it is excluded from the static unions. --- # Search helpers URL: https://paramour.dev/docs/reference/core/search 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](/docs/reference/wire-format)'s numbered rule set; this page documents the function contracts. ## decodeSearch [#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): ```ts twoslash import { decodeSearch, p } from "paramour"; const config = { page: p.integer().default(1), q: p.string().optional(), }; const fromUrl = decodeSearch(config, new URLSearchParams("?page=2")); // ^? const fromProps = decodeSearch(config, { page: "2", q: "socks" }); // Optional third argument: anchor a thrown SearchDecodeError to a route. decodeSearch(config, { page: "x" }, "/products"); ``` The third argument, `routePath`, sets a thrown `SearchDecodeError`'s `route` field and its message header (`Failed to decode search params for /products:`). The route methods pass `route.path` automatically; a bare `decodeSearch` call without it throws a route-less error. 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`](/docs/reference/core/errors#related-types) per failed key — into a single [`SearchDecodeError`](/docs/reference/core/errors#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`](#rawsearch) config branches to the whole-object schema path: every source key reaches the schema, which owns stripping or passing through extras itself. ## safeDecodeSearch [#safedecodesearch] The [`SafeResult`](/docs/reference/core/routes#saferesult) twin — takes the route object, returns instead of throwing: ```ts twoslash import { defineAppRoute, p, safeDecodeSearch } from "paramour"; const productsRoute = defineAppRoute("/products", { search: { page: p.integer().default(1), tags: p.csv() }, }); const result = safeDecodeSearch( productsRoute, new URLSearchParams("?page=abc"), ); if (result.status === "error") { result.error.issues; // ^? } ``` Only a `SearchDecodeError` becomes the `error` arm; source-contract violations, foreign errors, and async-schema misuse stay loud. ## encodeSearch [#encodesearch] Encodes an input object to ordered wire pairs — the decoded value layer, before any percent-encoding: ```ts twoslash import { encodeSearch, p } from "paramour"; const config = { page: p.integer().default(1), tags: p.csv(), }; const pairs = encodeSearch(config, { page: 3, tags: ["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 throws `SerializeError`. * **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`](#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 [#buildsearchstring] The byte layer: turns wire pairs into the final `?…` string. ```ts twoslash import { buildSearchString } from "paramour"; const qs = buildSearchString([ ["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 [#searchtostring] `encodeSearch` + `buildSearchString` in one step — the exact query half `href` builds: ```ts twoslash import { p, searchToString } from "paramour"; const config = { page: p.integer().default(1), q: p.string().optional(), }; const qs = searchToString(config, { page: 1, q: "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 [#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: ```ts twoslash import { defineAppRoute, rawSearch } from "paramour"; import * as v from "valibot"; export const legacyRoute = defineAppRoute("/legacy", { search: rawSearch(v.object({ from: v.string(), to: v.string() })), }); declare const props: import("paramour").RouteProps; const search = await legacyRoute.parseSearch(props); // ^? ``` 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 `href` accepts only the raw wire record (`Record`) for such a route. If you need bidirectional per-key transforms, that's `p.custom`. A schema failure still surfaces as the standard `SearchDecodeError` with `issues[]`; a root-level schema issue is keyed `""`. ## isRawSearch [#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](/docs/guides/nuqs), devtools) can branch on the slot's shape without duplicating the marker literal; app code rarely needs it. ## standardSearchSchema [#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`: ```ts twoslash import { defineAppRoute, p, standardSearchSchema } from "paramour"; const productsRoute = defineAppRoute("/products", { search: { page: p.integer().default(1), q: p.string().optional() }, }); const searchSchema = standardSearchSchema(productsRoute); // ^? ``` 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 [#low-level-primitives] ### serializeValue [#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: ```ts twoslash import { p, serializeValue } from "paramour"; const wire = serializeValue( p.isoDate(), 'search param "since"', new Date("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 [#related-types] | Type | What it is | | -------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | `SearchConfig` | A codec-map search config: `Record`. | | `SearchSource` | What the decoders accept: Next's `searchParams` record or a `URLSearchParams`. Both arrive already percent-decoded. | | `SearchOutputOf` | The decoded output type of a `search:` slot — codec map and `rawSearch` alike. What `parseSearch` resolves to. | | `InferSearchInput` | The encode-side input of a codec map: required keys stay required; optional, defaulted, and array keys may be omitted. | | `InferSearchOutput` | The parse-side output of a codec map: every declared key present; optional presence adds `undefined` to the value type. | | `RawSearch` | The branded marker `rawSearch` returns — what a route's `search:` slot holds in raw mode. | | `StandardSearchSchema` | The schema type `standardSearchSchema` returns: wire-shaped record in, decoded search out. | --- # App Router hooks URL: https://paramour.dev/docs/reference/next/app-hooks Import from `@paramour-js/next/app`. These are client-component hooks — the module carries `"use client"`, so any component calling them must be in the client bundle. Each one layers over Next's `useParams()` / `useSearchParams()`, and App-Router params are synchronously available on the client: there is no loading state, and results are SSR-consistent. Every hook is gated to app-branded routes (from `defineAppRoute`). Handing one a `definePagesRoute` route is a [compile error](#the-wrong-router-gate), not a runtime surprise. Every hook also accepts an optional [`SelectOptions`](#selectoptions) bag as its second argument. The safe/throwing split mirrors core's server-side `safeParse` vs `parse`: the plain hooks return a `SafeResult` union so a hand-mangled URL renders a fallback instead of crashing, and the `OrThrow` hooks throw the decode error in render to your nearest client error boundary. The [hooks guide](/docs/guides/hooks) compares the two styles. ## useRouteParams [#userouteparams] Decoded route params as a `SafeResult`, discriminated on `status`. ```ts function useRouteParams(route: R): SafeResult>; function useRouteParams( route: R, options: SelectOptions, U>, ): SafeResult; ``` ```tsx twoslash title="app/product/[id]/product-badge.tsx" // @filename: app/product/[id]/route.def.ts import { defineAppRoute, p } from "paramour"; export const productRoute = defineAppRoute("/product/[id]", { params: { id: p.integer() }, }); // @filename: app/product/[id]/product-badge.tsx "use client"; import { useRouteParams } from "@paramour-js/next/app"; import { productRoute } from "./route.def"; export function ProductBadge() { const params = useRouteParams(productRoute); // ^? if (params.status === "error") { return

{params.error.message}

; } return

Product #{params.data.id}

; } ``` After the `status` guard, `params.data` is fully narrowed to the route's decoded param types. Outside an App-Router tree — including the initial render of every pages-router page in a hybrid app — Next's `useParams()` returns `null`; this hook degrades that to an empty source, so required params surface as ordinary "missing" issues on the error arm, never a crash. ## useRouteParamsOrThrow [#userouteparamsorthrow] Decoded route params directly, or a thrown `ParamsDecodeError` (from `paramour`) to the nearest client error boundary. ```ts function useRouteParamsOrThrow(route: R): InferRouteParams; function useRouteParamsOrThrow( route: R, options: SelectOptions, U>, ): U; ``` ```tsx twoslash title="app/product/[id]/product-heading.tsx" // @filename: app/product/[id]/route.def.ts import { defineAppRoute, p } from "paramour"; export const productRoute = defineAppRoute("/product/[id]", { params: { id: p.integer() }, }); // @filename: app/product/[id]/product-heading.tsx "use client"; import { useRouteParamsOrThrow } from "@paramour-js/next/app"; import { productRoute } from "./route.def"; export function ProductHeading() { const params = useRouteParamsOrThrow(productRoute); // ^? return

Product #{params.id}

; } ``` Reach for this when the route is only reachable through links you built with `href` — then a malformed URL genuinely is exceptional and per-component fallbacks would be noise. If the URL is something users edit or share, prefer `useRouteParams`. While the URL stays invalid, re-renders rethrow the *same* error instance rather than re-decoding; once the URL is fixed, a reset error boundary gets a fresh decode. ## useSearch [#usesearch] Decoded search params as a `SafeResult`, discriminated on `status`. ```ts function useSearch(route: R): SafeResult>; function useSearch( route: R, options: SelectOptions, U>, ): SafeResult; ``` ```tsx twoslash title="app/products/filter-summary.tsx" // @filename: app/products/route.def.ts import { defineAppRoute, p } from "paramour"; export const productsRoute = defineAppRoute("/products", { search: { page: p.integer().default(1), q: p.string().optional() }, }); // @filename: app/products/filter-summary.tsx "use client"; import { useSearch } from "@paramour-js/next/app"; import { productsRoute } from "./route.def"; export function FilterSummary() { const search = useSearch(productsRoute); // ^? if (search.status === "error") { return

{search.error.message}

; } return (

Page {search.data.page} {search.data.q && <> — “{search.data.q}”}

); } ``` The result is stabilized on the *declared slice* of the URL: a change that only touches keys the route doesn't own — `?utm_source=` churn — returns the previous result by identity, without re-decoding, so memoized children don't re-render. ## useSearchOrThrow [#usesearchorthrow] Decoded search params directly, or a thrown `SearchDecodeError` (from `paramour`) to the nearest client error boundary. ```ts function useSearchOrThrow(route: R): SearchOutputOf; function useSearchOrThrow( route: R, options: SelectOptions, U>, ): U; ``` ```tsx twoslash title="app/products/page-title.tsx" // @filename: app/products/route.def.ts import { defineAppRoute, p } from "paramour"; export const productsRoute = defineAppRoute("/products", { search: { page: p.integer().default(1), q: p.string().optional() }, }); // @filename: app/products/page-title.tsx "use client"; import { useSearchOrThrow } from "@paramour-js/next/app"; import { productsRoute } from "./route.def"; export function PageTitle() { const search = useSearchOrThrow(productsRoute); // ^? return

Results — page {search.page}

; } ``` Same trade-off as `useRouteParamsOrThrow`: use it where a malformed URL is genuinely exceptional. Search params are usually the URLs users *do* edit and share (filters, queries), so the `SafeResult` form is the more common fit here. ## SelectOptions [#selectoptions] The optional second argument to every hook: project the decoded value down to the slice a component actually uses, with result-equality checking so an unchanged selection keeps its previous reference while *other* params churn. | Option | Default | Description | | ---------- | ------------ | ------------------------------------------------------------------------------------------------------------------ | | `select` | — (required) | Pure projection of the decoded value. Runs on the success arm only; error results pass through untouched. | | `equality` | `Object.is` | Result-equality mode for the selected value. Pass `"shallow"` for one-level comparison of tuple/object selections. | ```tsx twoslash title="app/products/page-badge.tsx" // @filename: app/products/route.def.ts import { defineAppRoute, p } from "paramour"; export const productsRoute = defineAppRoute("/products", { search: { page: p.integer().default(1), q: p.string().optional() }, }); // @filename: app/products/page-badge.tsx "use client"; import { useSearch } from "@paramour-js/next/app"; import { productsRoute } from "./route.def"; export function PageBadge() { const page = useSearch(productsRoute, { select: (s) => s.page }); // ^? return page.status === "success" ? p. {page.data} : null; } ``` Semantics worth knowing: * **Why select.** Without it, a component re-renders whenever *any* declared param changes. With it, a component selecting `page` keeps a referentially-identical result while `q` churns — the selected wrapper itself is reused, so `memo`/`useMemo` consumers stay quiet. * **Selector identity never matters.** Inline arrows are fine — the selector function is not compared across renders. * **Purity is assumed.** When the underlying decoded value is reference-stable, the selector is *not* re-run, so it must not read changing outside state. * **A selector throw is a code bug**, and propagates to the nearest error boundary — it never becomes the `SafeResult` error arm, which is reserved for URL data problems. * **`equality` defaults to `Object.is`** — free and correct for primitive selections. For selections that build a fresh object or tuple each time, opt in to `"shallow"` (one level deep, never recursive). ## The wrong-router gate [#the-wrong-router-gate] Every hook constrains its route to `AnyAppRoute`, so passing a pages-branded route fails to compile at the call site — not as a hydration mystery three components deep: ```ts twoslash // @errors: 2345 // @filename: lib/routes.ts import { definePagesRoute, p } from "paramour"; export const legacyRoute = definePagesRoute("/legacy/[id]", { params: { id: p.integer() }, }); // @filename: app/oops.tsx "use client"; import { useRouteParams } from "@paramour-js/next/app"; import { legacyRoute } from "../lib/routes"; export function Oops() { return useRouteParams(legacyRoute).status; } ``` For components under `pages/`, import the [Pages Router hooks](/docs/reference/next/pages-hooks) instead. --- # @paramour-js/next URL: https://paramour.dev/docs/reference/next `@paramour-js/next` is the Next.js integration for paramour: the build-time registry wrapper, client hooks for both routers, and the `paramour` CLI. The package ships five module entry points plus a binary — import from the entry that matches where the code runs. ## `.` — build-time integration [#--build-time-integration] The main entry exports the Next config wrapper and its supporting types: [`withTypedRoutes`](/docs/reference/next/with-typed-routes) generates and validates the `paramour-env.d.ts` registry artifact during `next dev` and `next build`, with [`WithTypedRoutesOptions`](/docs/reference/next/with-typed-routes#withtypedroutesoptions) (`outFile`, `strict`) controlling where the artifact lives and whether drift fails the build. It also exports [`ParamourConfig`](/docs/reference/next/with-typed-routes#paramourconfig), the type of the `paramour.config.ts` file the CLI reads, and [`RouteCollisionError`](/docs/reference/next/with-typed-routes#routecollisionerror), thrown when two files resolve to one URL. These four are the entry's entire public surface. ## `./app` — App Router hooks [#app--app-router-hooks] [Client hooks for App Router components](/docs/reference/next/app-hooks): `useRouteParams`, `useSearch`, and their `OrThrow` variants, all taking the same route objects you define with `defineAppRoute`. App-Router params are synchronously available on the client, so results are two-arm `SafeResult` unions with no loading state. Every hook accepts an optional [`SelectOptions`](/docs/reference/next/app-hooks#selectoptions) projection. The module carries `"use client"` — these are client-component hooks. ## `./pages` — Pages Router hooks [#pages--pages-router-hooks] [Client hooks for Pages Router components](/docs/reference/next/pages-hooks): `useRouteParams` and `useSearch` for routes defined with `definePagesRoute`. They return a three-state [`RouterResult`](/docs/reference/next/pages-hooks#routerresult) — `SafeResult` plus a `pending` arm for the pre-hydration render of statically-optimized pages — and there are deliberately no `OrThrow` variants. The same `SelectOptions` bag applies. ## `./testing` — testing provider [#testing--testing-provider] [A provider for unit-testing client components](/docs/reference/next/testing) that call the hooks: `ParamourTestingProvider` and `withParamourTesting`, the latter a wrapper factory for testing-library's `wrapper` option. One provider feeds both router flavors, and it overrides the hooks' framework reads through context — no `next/*` module mocking, any test runner. The entry depends only on React and is client-only, like the hook modules. ## `./devtools-seam` — devtools contract (types only) [#devtools-seam--devtools-contract-types-only] A types-only entry: the contract between the observation seam inside the hooks and the `@paramour-js/devtools` panel that consumes it. You import from it only when building tooling on top of the seam — application code never needs it. The panel itself is documented in the [`@paramour-js/devtools` reference](/docs/reference/devtools). ## The `paramour` binary [#the-paramour-binary] Installing the package puts a [`paramour` CLI](/docs/reference/next/cli) on your path with six commands — [`generate`](/docs/reference/next/cli/generate), [`check`](/docs/reference/next/cli/check), [`init`](/docs/reference/next/cli/init), [`list`](/docs/reference/next/cli/list), [`doctor`](/docs/reference/next/cli/doctor), and [`skills`](/docs/reference/next/cli/skills) — covering artifact generation, CI drift verification, project setup, route inspection, setup diagnosis, and agent-skill installation. One exit-code contract holds across all of them. --- # Pages Router hooks URL: https://paramour.dev/docs/reference/next/pages-hooks Import from `@paramour-js/next/pages`, with routes built by `definePagesRoute`. These hooks layer over `next/router`'s `useRouter()`, and they carry no `"use client"` directive — that's an App Router concept; these are ordinary Pages-bundle code. The Pages Router has a platform fact the App Router doesn't: on a statically-optimized page, `router.query` is empty until hydration completes (`router.isReady` flips). Instead of papering over it, the result type adds a third arm — every hook returns a [`RouterResult`](#routerresult). Both hooks are gated to pages-branded routes: an app-branded route (from `defineAppRoute`) at either call site is a compile error, the mirror of the [App hooks' gate](/docs/reference/next/app-hooks#the-wrong-router-gate). Both accept the same optional [`SelectOptions`](#selectoptions) bag. ## RouterResult [#routerresult] The three-state result type both hooks return. ```ts type RouterResult = SafeResult | { status: "pending" }; ``` It is literally core's `SafeResult` plus a `pending` member, so both routers' results destructure identically — `status` is `"success"`, `"error"`, or `"pending"`, and neither `data` nor `error` is reachable without a status check. The `pending` arm covers exactly the pre-`isReady` render of a statically-optimized page; on `getServerSideProps` pages the first render is already populated, so `pending` never surfaces there. The `pending` object is referentially stable across renders. ## useRouteParams [#userouteparams] Decoded route params as a `RouterResult`. ```ts function useRouteParams(route: R): RouterResult>; function useRouteParams( route: R, options: SelectOptions, U>, ): RouterResult; ``` The exhaustive `switch` is the pattern to copy — the compiler holds you to all three arms: ```tsx twoslash title="components/product-badge.tsx" // @filename: lib/routes.ts import { definePagesRoute, p } from "paramour"; export const productRoute = definePagesRoute("/products/[id]", { params: { id: p.integer() }, }); // @filename: components/product-badge.tsx import { useRouteParams } from "@paramour-js/next/pages"; import { productRoute } from "../lib/routes"; export function ProductBadge() { const params = useRouteParams(productRoute); // ^? switch (params.status) { case "error": return

{params.error.message}

; case "pending": return

Loading…

; case "success": return

Product #{params.data.id}

; } } ``` `router.query` merges route params and search params into one bag; this hook reads only the route's own segment names from it, so query junk never reaches the decode. ## useSearch [#usesearch] Decoded search params as a `RouterResult`. ```ts function useSearch(route: R): RouterResult>; function useSearch( route: R, options: SelectOptions, U>, ): RouterResult; ``` ```tsx twoslash title="components/search-summary.tsx" // @filename: lib/routes.ts import { definePagesRoute, p } from "paramour"; export const productsRoute = definePagesRoute("/products/[id]", { params: { id: p.integer() }, search: { page: p.integer().default(1), q: p.string().optional() }, }); // @filename: components/search-summary.tsx import { useSearch } from "@paramour-js/next/pages"; import { productsRoute } from "../lib/routes"; export function SearchSummary() { const search = useSearch(productsRoute); // ^? switch (search.status) { case "error": return

{search.error.message}

; case "pending": return null; case "success": return

Page {search.data.page}

; } } ``` Before decoding, the hook subtracts the route's own path-param names from the merged `query` — the client twin of `parseContext`'s server-side subtraction — so a route param named like a search key can't shadow it. Like the App hooks, results are stabilized on the declared slice of the URL: `?utm_source=` churn returns the previous result by identity. ## SelectOptions [#selectoptions] The same options bag as the [App Router hooks](/docs/reference/next/app-hooks#selectoptions), with identical semantics: | Option | Default | Description | | ---------- | ------------ | ---------------------------------------------------------------------------------------------------------------------- | | `select` | — (required) | Pure projection of the decoded value. Runs on the success arm only; error **and pending** arms pass through untouched. | | `equality` | `Object.is` | Result-equality mode for the selected value; `"shallow"` opts in to one-level comparison. | ```tsx twoslash title="components/page-badge.tsx" // @filename: lib/routes.ts import { definePagesRoute, p } from "paramour"; export const productsRoute = definePagesRoute("/products/[id]", { params: { id: p.integer() }, search: { page: p.integer().default(1), q: p.string().optional() }, }); // @filename: components/page-badge.tsx import { useSearch } from "@paramour-js/next/pages"; import { productsRoute } from "../lib/routes"; export function PageBadge() { const page = useSearch(productsRoute, { select: (s) => s.page }); // ^? return page.status === "success" ? p. {page.data} : null; } ``` The one difference from the App module is mechanical: the selected result is `RouterResult` — the `pending` arm stays in the union and is never fed to the selector. ## No OrThrow variants — by design [#no-orthrow-variants--by-design] Throwing on `pending` would flash the error boundary on every statically-optimized page's first render, and returning `T | undefined` would make the name a lie. The three-state union forcing the check *is* the design. If your page has `getServerSideProps`, read typed props from `route.parseContext(ctx)` / `route.safeParseContext(ctx)` on the server instead of reaching for a client hook — the [hooks guide](/docs/guides/hooks#pages-router) shows the pattern. ## Rendering under the wrong router [#rendering-under-the-wrong-router] The type-level gate catches wrong-route mistakes, but component *placement* is invisible to the type system: in a hybrid app, a component holding a legitimately pages-branded route can still be rendered under `app/`, where `next/router` is never mounted. Instead of Next's "NextRouter was not mounted" (which points at the wrong fix), these hooks throw a `ParamourError` naming the actual mistake — import from `@paramour-js/next/app` and pass an app route for components that live in the App Router tree. --- # Testing provider URL: https://paramour.dev/docs/reference/next/testing Import from `@paramour-js/next/testing`. The entry exports a provider that overrides the [hooks'](/docs/reference/next/app-hooks) framework reads through React context, so client components calling `useSearch` / `useRouteParams` (either router) can be unit-tested without mocking `next/navigation` or `next/router` — no `vi.mock` / `jest.mock`, no bundler alias, and no collision with anything else the component imports from `next/navigation` (`redirect`, `notFound` stay real). One provider feeds **both** router flavors, so hybrid apps and Pages components need no second import. The module depends only on React — it imports neither `next/*` nor `@testing-library/*`. It is client-only and carries `"use client"` like the hook modules; in a test runner that just means a DOM environment (jsdom, happy-dom, browser mode). The [testing guide](/docs/guides/testing) covers usage patterns — and why server code (`parse`/`safeParse`, server components) needs none of this. ## withParamourTesting [#withparamourtesting] Returns a wrapper component for testing-library's `wrapper` option (mirroring nuqs's `withNuqsTestingAdapter`). ```ts function withParamourTesting( options?: ParamourTestingOptions, ): (props: { children?: ReactNode }) => ReactElement; ``` ```tsx title="app/products/filter-summary.test.tsx" import { withParamourTesting } from "@paramour-js/next/testing"; import { render } from "@testing-library/react"; import { FilterSummary } from "./filter-summary"; render(, { wrapper: withParamourTesting({ pathname: "/products", search: "?page=2&q=paramour", }), }); ``` ## ParamourTestingProvider [#paramourtestingprovider] The provider itself, for composing your own wrappers — Storybook decorators, custom render helpers, or tests that change the URL mid-test by rerendering with new props. ```ts function ParamourTestingProvider( props: ParamourTestingOptions & { children?: ReactNode }, ): ReactElement; ``` ```tsx title=".storybook/preview.tsx" import { ParamourTestingProvider } from "@paramour-js/next/testing"; export const decorators = [ (Story) => ( ), ]; ``` A provider instance holds one stable adapter pair for its lifetime; prop changes update what the hooks *read*, they never swap hook implementations. Drive mid-test URL changes through ordinary rerenders with new props — the component under test keeps its state. ## ParamourTestingOptions [#paramourtestingoptions] Every field is optional; an empty call models `"/"` with no params and no search string. | Option | Default | Description | | ----------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `isReady` | `true` | Pages flavor only. `false` is the pre-hydration state of a statically-optimized page (`query` not yet populated) — the hooks report their `pending` arm. | | `mounted` | `true` | Pages flavor only. `false` reproduces `next/router`'s throw-on-unmounted state (a pages component rendered under `app/`) — the pages hooks translate it to a `ParamourError` naming the actual mistake. | | `onReplace` | — | Captures `replace(href)` from either flavor's router, including devtools-driven navigations. A plain `vi.fn()` works. | | `params` | `{}` | Route params as **raw wire values**, exactly as Next hands them (`{ id: "42" }`, not `{ id: 42 }`). `null` is the hybrid-app `useParams()` state outside an App-Router tree and passes through as `null`; only omitted means `{}`. | | `pathname` | `"/"` | The current pathname. | | `search` | `""` | The search string, as a `string` or a `URLSearchParams`. `"?page=2"` and `"page=2"` are both accepted. | ## Pages-derived values [#pages-derived-values] The Pages-flavor router the provider fakes derives its remaining fields from the options rather than taking them separately, matching what real Next computes: * **`asPath`** is `pathname` plus the normalized `search` (`"/products?page=2"`) — no separate option, so the two can't disagree. * **`query`** is the merged bag real Next builds: search entries first (single value → scalar, repeated key → `string[]`), then `params` entries override — path params win. * **`replace()`** resolves `Promise` → `true`, as a completed replace does in real `next/router`. * **`isReady`** is only meaningful while mounted; `mounted: false` throws before any field is read. App-flavor reads are direct: `useParams()` returns `params`, `usePathname()` returns `pathname`, `useSearchParams()` returns a `URLSearchParams` built from `search`, and the router's `replace(href)` forwards to `onReplace`. --- # withTypedRoutes URL: https://paramour.dev/docs/reference/next/with-typed-routes Everything on this page imports from the package's main entry, `@paramour-js/next`. These four exports are that entry's entire public surface. ## withTypedRoutes [#withtypedroutes] Wraps a Next config so the [registry artifact](/docs/concepts/registry) (`paramour-env.d.ts`) is generated and kept current by `next dev` and validated by `next build`. ```ts function withTypedRoutes( config: C | ((phase: string, ctx: unknown) => C | Promise), options?: WithTypedRoutesOptions, ): (phase: string, ctx: unknown) => C | Promise; ``` It accepts either config form — a plain object or the `(phase, ctx) => config` function, possibly async — and always returns the function form. Next's phase argument selects the behavior: * **`next build`** — one generation pass before the config is returned, so the build type-checks against fresh routes. If the pass changed the file (drift, or a missing artifact), a loud warning names the routes that appeared and disappeared — or the build fails under `strict: true`. * **`next dev`** — one immediate generation pass, then a debounced watcher that regenerates as route directories change. Two single-writer guards (an in-process singleton and a cross-process pidfile lock) keep repeat config evaluations and a concurrent `paramour generate --watch` from fighting over the file. * **every other phase** (lint, info, …) — pass-through; no generation. ```ts twoslash title="next.config.ts" import type { NextConfig } from "next"; import { withTypedRoutes } from "@paramour-js/next"; const nextConfig: NextConfig = { reactStrictMode: true, }; export default withTypedRoutes(nextConfig, { strict: true }); ``` The wrapper reads exactly one field off the resolved config — `pageExtensions`, so custom extensions scan correctly — and passes everything else through untouched. Because it returns the function form, it composes with other wrappers by going **outermost**: ```ts export default withTypedRoutes(withMDX(nextConfig), { strict: true }); ``` Generation is never load-bearing for your app: an incidental generation failure (transient I/O, an unreadable directory) logs a warning and lets `next dev`/`next build` continue with stale route types. Two states are the deliberate exceptions and throw during config evaluation, because Next itself has no valid build for them: * a [`RouteCollisionError`](#routecollisionerror) — two files resolving to one URL; * a populated route directory that Next is configured to ignore (a config error discovery refuses to paper over). If no route directory exists at all (`app/`, `pages/`, `src/app/`, `src/pages/`), the wrapper warns once and skips generation entirely. The config file (`paramour.config.ts`) configures the **CLI**. The wrapper takes its options directly — `outFile` via `WithTypedRoutesOptions` — and reads `pageExtensions` from the Next config it wraps. If you move the artifact with `outFile` in `paramour.config.ts`, pass the same value to `withTypedRoutes` too, or the two will write to different paths. ## WithTypedRoutesOptions [#withtypedroutesoptions] The second argument to `withTypedRoutes`. Both fields are optional. | Option | Default | Description | | --------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `outFile` | `paramour-env.d.ts` | Artifact location; relative paths resolve against the project root. The escape hatch for monorepos where the Next app root isn't where the file should live. | | `strict` | `false` | Upgrade build-phase drift from a loud warning to a build failure. | `strict: true` is for teams that treat the committed artifact as the law: if a `next build` finds the artifact stale — or missing, which counts as drift — the build fails instead of warning. The file is regenerated *first*, then the error is thrown, so the fix is always "commit the corrected file". The default is `false`, which stays friendly to gitignored-artifact workflows and CI images that generate on the fly. This docs site runs `strict: true`; the [CLI guide](/docs/guides/cli#check) compares it with the `paramour check` gate. Only *drift* can fail a strict build. An incidental generation failure still degrades to stale types with a warning, strict or not. ## ParamourConfig [#paramourconfig] The shape of `paramour.config.ts` (or `.mjs`/`.json`) at the project root — the **CLI's** config file, exported here so you can type the default export. Every field is optional; CLI precedence is flags → this file → automatic discovery. ```ts twoslash title="paramour.config.ts" import type { ParamourConfig } from "@paramour-js/next"; export default { pageExtensions: ["tsx", "ts"], routeFiles: ["app/**/route.def.ts"], } satisfies ParamourConfig; ``` | Field | Default | Description | | ---------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `appDir` | discovered `app/` or `src/app/` | App directory, relative to the project root. | | `pagesDir` | discovered `pages/` or `src/pages/` | Pages directory, relative to the project root. | | `outFile` | `paramour-env.d.ts` | Artifact path, relative to the project root. | | `pageExtensions` | `["tsx", "ts", "jsx", "js"]` | Page extensions, **no leading dots** — a leading dot is rejected because it would silently match nothing. | | `routeFiles` | automatic content scan | Globs (relative to the project root) of modules exporting route definitions. Read by `list`/`doctor` only — generation never uses it. Set it when the automatic `defineAppRoute`/`definePagesRoute` source scan misfires. | The config file is discovered at the project root only (no upward traversal), first match wins: `paramour.config.ts`, then `paramour.config.mjs`, then `paramour.config.json`. `.ts`/`.mjs` files default-export the object. Validation is strict — an unknown key (say, a `pagesExtensions` typo) is an error, mapped to CLI exit `2`, not silently ignored. ## RouteCollisionError [#routecollisionerror] Thrown when two files resolve to one URL — states Next itself refuses to build, so no valid artifact exists and the scanners throw instead of emitting one. It's a plain `Error` subclass with `name: "RouteCollisionError"`. Three shapes trigger it, checked within each router and across the two: * **Same path from both routers** — `app/pricing/page.tsx` and `pages/pricing.tsx` both claim `/pricing`. * **Different slug names at one position** — `/x/[id]` beside `/x/[slug]` ("You cannot use different slug names for the same dynamic path"). This includes `[...a]` beside `[[...a]]` at the same level; `[id]` beside `[...slug]` is *not* a collision — that's Next's documented priority pattern. * **Optional catch-all vs. its base path** — `/docs` beside `/docs/[[...slug]]`: an optional catch-all also matches its own base path. ```txt route collision: "/x/[id]" (app) and "/x/[slug]" (app) declare conflicting dynamic segments ([id] vs [slug]) at the same position — Next refuses different slug names for the same dynamic path ``` Where you see it depends on the entry point, but the fix is the same: rename or remove one of the conflicting files, matching what Next itself would demand. * `withTypedRoutes` rethrows it from config evaluation, dev and build phases alike — naming the actual problem instead of leaving a stale artifact to confuse the type errors that follow. * The CLI maps it to exit `2` (an operational error, not drift). * The one non-fatal path is a running watcher (`next dev` or `paramour generate --watch`): a mid-watch collision is usually a file mid-move, so it logs loudly on every rescan, keeps the last good artifact on disk, and keeps watching for the fix. --- # paramour check URL: https://paramour.dev/docs/reference/next/cli/check The CI gate. `check` is exactly [`generate --check`](/docs/reference/next/cli/generate): re-scan the route directories, byte-compare the result against the committed artifact, exit `1` on drift, **never write**: ```bash npx paramour check ``` ```txt paramour: paramour-env.d.ts is up to date ``` On drift, the report names what appeared and disappeared, so the fix is obvious — rerun `generate` and commit: ```txt paramour: paramour-env.d.ts is out of date. + /product/[id] (app) - /products/[id] (app) Run `paramour generate` and commit the result. ``` A **missing artifact counts as drift** — CI can't silently degrade to unchecked paths. So does byte drift with an identical route set (a hand-edited artifact), reported as `content differs from generator output`. ## Flags [#flags] `check` accepts the shared route-resolution flags and nothing else — in particular it rejects `--check` and `--watch` as unknown options (it *is* the check; there is nothing to watch): | Flag | Default | Description | | -------------------------- | ----------------------------------- | -------------------------------------------- | | `--app-dir ` | discovered `app/` or `src/app/` | App directory. | | `--pages-dir ` | discovered `pages/` or `src/pages/` | Pages directory. | | `--out-file ` | `paramour-env.d.ts` | Artifact path, relative to the project root. | | `--page-extensions ` | `tsx,ts,jsx,js` | Comma-separated, no leading dots. | | `--help`, `-h` | — | Show usage. | ## CI usage [#ci-usage] Put it in front of the build so a stale registry is a failed build with a clear message, not a type-error cascade later in the log: ```json title="package.json" { "scripts": { "build": "paramour check && next build" } } ``` The complementary wiring is [`withTypedRoutes(config, { strict: true })`](/docs/reference/next/with-typed-routes#withtypedroutesoptions), which upgrades build-phase drift into a `next build` failure — this docs site does both. Prefer `check` as the first line of defense: it fails explicitly and first, rather than mid-`next build`. ## Exit codes [#exit-codes] | Code | When | | ---- | -------------------------------------------------------------------------- | | `0` | The artifact matches the filesystem byte-for-byte. | | `1` | Drift — including a missing or hand-edited artifact. | | `2` | Usage/config errors, no route directories, route collisions, I/O failures. | --- # paramour doctor URL: https://paramour.dev/docs/reference/next/cli/doctor Diagnoses the whole setup in one pass. When something about a project feels off, run doctor before debugging by hand — it checks the boring causes first: ```bash npx paramour doctor ``` ```txt ✔ config: no paramour.config file — defaults in effect ✔ route directories: app/ ✔ artifact: paramour-env.d.ts is up to date ✔ next.config: next.config.ts wraps withTypedRoutes ✔ versions: paramour 0.4.0 satisfies @paramour-js/next 0.2.1's declared dependency ✔ skills: .claude/skills/paramour is up to date ✔ tsconfig: tsconfig.json includes paramour-env.d.ts ✔ route definitions: 3 found in 3 modules 3 of 3 filesystem routes have definitions doctor: 8 checks — 0 failed, 0 warnings ``` (The sample shows a project with skills installed in one tool directory; each additional installed skills target adds its own `skills` line to the count.) Each check degrades independently — doctor exists to diagnose broken setups, so a probe that itself throws becomes a finding, never a crash. ## The checks [#the-checks] Seven fixed checks plus one `skills` check per installed skills target, in report order — so eight is the floor, not the total: a project that never installed skills (or has no agent tooling) still gets a single summary `skills` line, while one with copies in both `.claude/` and `.cursor/` reports nine. `fail` means a verification you care about is untrue; `warn` is advisory and never affects the exit code. | Check | Verifies | Can fail? | | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | | `config` | `paramour.config.{ts,mjs,json}` parses and validates (no config at all is a pass — defaults in effect). | fail | | `route directories` | An app and/or pages directory resolves, config dirs honored. | fail | | `artifact` | The artifact exists and is byte-current — the [`check`](/docs/reference/next/cli/check) engine; drift details name the routes. | fail | | `next.config` | The config file wraps `withTypedRoutes`. Warn-level only: CLI-only workflows (generate in a script, check in CI) are legitimate. | warn only | | `versions` | `paramour` and `@paramour-js/next` resolve in `node_modules` (fail if not), and the installed `paramour` matches the exact version `@paramour-js/next` declares as its dependency (warn on mismatch — the two packages version independently, so their own versions are never compared to each other). | fail | | `skills` | Installed [agent skills](/docs/reference/next/cli/skills) match this package's bundled content — one check line per installed target. Warn-level only: stale guidance degrades agent output but breaks nothing, and the deliberate CI gate is `skills --check`. Projects without agent tooling (or that never installed skills) get a pass line, not a nag. | warn only | | `tsconfig` | The tsconfig covers the artifact file, so the registry augmentation is actually in your program. | warn only | | `route definitions` | Route-definition discovery is healthy — how many definitions found, how many filesystem routes they cover, plus load failures and duplicates. Evaluates matched modules, like [`list`](/docs/reference/next/cli/list). | warn only | ## Flags [#flags] | Flag | Default | Description | | -------------- | ------- | ------------------------ | | `--json` | off | Machine-readable output. | | `--help`, `-h` | — | Show usage. | `--json` emits `{ checks, status }`, where each check carries `label`, `status` (`"pass"` / `"warn"` / `"fail"`), and optional `detail` lines, and the top-level `status` is the worst of them. ## Exit codes [#exit-codes] Doctor is a verification, so its exit codes follow `check`'s class: | Code | When | | ---- | ---------------------------------------------------------------- | | `0` | Every check passed — warnings allowed. | | `1` | Any check failed. | | `2` | Doctor itself could not run (usage errors, an unexpected crash). | Because warnings exit `0`, doctor can run in CI as a broader gate than `check` without failing builds over advisory findings. --- # paramour generate URL: https://paramour.dev/docs/reference/next/cli/generate Scans the route directories and writes the [registry artifact](/docs/concepts/registry) — the `paramour-env.d.ts` file that narrows route constructors to paths that actually exist: ```bash npx paramour generate ``` ```txt paramour: wrote paramour-env.d.ts (3 app routes) ``` The write is deterministic and idempotent: byte-identical output is a skipped write (reported as `unchanged`), so timestamps don't churn and watchers don't loop. ## Flags [#flags] | Flag | Default | Description | | -------------------------- | ----------------------------------- | -------------------------------------------------------------------- | | `--app-dir ` | discovered `app/` or `src/app/` | App directory. | | `--pages-dir ` | discovered `pages/` or `src/pages/` | Pages directory. | | `--out-file ` | `paramour-env.d.ts` | Artifact path, relative to the project root. | | `--page-extensions ` | `tsx,ts,jsx,js` | Comma-separated, no leading dots. | | `--check` | off | Verify the artifact is current; exit `1` on drift, **never writes**. | | `--watch` | off | Regenerate on route-directory changes. | | `--help`, `-h` | — | Show usage. | `--check` and `--watch` are mutually exclusive — passing both exits `2`. Flag values take precedence over `paramour.config`, which takes precedence over discovery; an explicitly given directory must exist or the command exits `2`. Discovery only runs for directories you didn't pass, so passing both `--app-dir` and `--pages-dir` bypasses it entirely. ## The artifact workflow [#the-artifact-workflow] `generate` writes the file; committing it is what makes it useful. The artifact is generated *and committed* — treat it like a lockfile: 1. Add, move, or delete a route, then run `paramour generate` (the `"paramour"` package script init adds runs exactly this). 2. Commit the regenerated file — route changes show up in review as a small readable diff. 3. Let [`paramour check`](/docs/reference/next/cli/check) prove the committed file honest in CI. During `next dev`, the [`withTypedRoutes`](/docs/reference/next/with-typed-routes) wrapper keeps the artifact current automatically, so manual runs mostly matter outside the dev server. ## `--watch` [#--watch] Regenerates on route-directory changes, for workflows outside `next dev` — an editor companion while you restructure routes, say: ```bash npx paramour generate --watch ``` ```txt paramour: watching /home/you/my-app/app ``` A pidfile lock makes watchers single-writer: if another live watcher already owns the artifact (usually a running `next dev` with `withTypedRoutes`), the command prints the owning pid and exits `0` — initial generation has already run, so nothing is stale. A failed initial generation warns and keeps watching; a route collision mid-watch (usually a file mid-move) logs on every rescan, keeps the last good artifact on disk, and keeps running. ## Exit codes [#exit-codes] | Code | When | | ---- | -------------------------------------------------------------------------------------------- | | `0` | Wrote the artifact, or it was already up to date; `--watch` declined by an existing watcher. | | `1` | `--check` found drift — only that. | | `2` | Usage/config errors, no route directories, route collisions, I/O failures. | --- # paramour CLI URL: https://paramour.dev/docs/reference/next/cli Installing `@paramour-js/next` puts a `paramour` binary on your path with six commands. Run it through your package manager — a `package.json` script (the `"paramour"` script that [init](/docs/reference/next/cli/init) adds), `pnpm exec paramour`, or `npx paramour`: ```bash npx paramour [options] ``` | Command | What it does | | ----------------------------------------------- | ------------------------------------------------------------------- | | [`generate`](/docs/reference/next/cli/generate) | Generate `paramour-env.d.ts` from the app and pages directories. | | [`check`](/docs/reference/next/cli/check) | Verify the artifact is current; exit `1` on drift, never writes. | | [`init`](/docs/reference/next/cli/init) | Set up paramour in this project, non-interactively. | | [`list`](/docs/reference/next/cli/list) | Print every route with its params/search shape. | | [`doctor`](/docs/reference/next/cli/doctor) | Diagnose the project's paramour setup. | | [`skills`](/docs/reference/next/cli/skills) | Install or verify the bundled agent skill for detected agent tools. | `paramour --help` (or `-h`, or bare `paramour help`) prints the command list; every command takes its own `--help` for its options. Running with no command, or an unknown one, prints usage and exits `2`. ## Exit codes [#exit-codes] One contract holds across every command, so CI scripting never has to guess: | Code | Meaning | | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `0` | Success. Warnings still exit `0` (`doctor` warnings, `list` coverage warnings, `init`'s manual-fallback prints). | | `1` | The thing you asked to verify is not true — `check` / `generate --check` drift, any failed `doctor` check, `skills --check` on missing or stale installed skills. Nothing else. | | `2` | Usage, configuration, or operational error: unknown flags, an invalid `paramour.config`, missing route directories, route collisions, I/O failures. | ## Configuration [#configuration] Commands that scan routes (`generate`, `check`, `list`) resolve their inputs with one precedence rule: **flags → `paramour.config` file → automatic discovery**. The config file is [`paramour.config.ts`](/docs/reference/next/with-typed-routes#paramourconfig) (or `.mjs`/`.json`) at the project root; every field is optional, and with no config at all the CLI infers `app/` / `src/app/` and `pages/` / `src/pages/`. Paths resolve against the current working directory — run the CLI where you'd run `next` itself. --- # paramour init URL: https://paramour.dev/docs/reference/next/cli/init Sets up paramour in a project, non-interactively: it runs straight through with defaults and prints one status line per step. Every step is idempotent — rerunning init never clobbers your edits — and individually skippable: ```bash npx paramour init ``` ```txt paramour init ✔ created paramour.config.ts ✔ wrapped next.config.ts with withTypedRoutes ✔ added "paramour" script to package.json ✔ wrote paramour-env.d.ts (1 route) setup: ✔ route directories: app/ ✔ dependencies declared: paramour, @paramour-js/next ✔ tsconfig.json includes paramour-env.d.ts Commit the generated artifact — `paramour check` verifies it stays current in CI. ``` ## The six steps [#the-six-steps] 1. **Scaffold `paramour.config.ts`** — every field in the scaffold is a commented-out default, so the file is a no-op until you edit it. An existing config (any of the three filenames) is skipped unless you pass `--force`. 2. **Wrap `next.config` with `withTypedRoutes`** — a source transform of your existing config file. If the file can't be transformed safely (or read, or doesn't exist), init prints the wrap snippet for you to apply by hand and still exits `0` — a printed instruction is a successful outcome. An already-wrapped config is skipped. 3. **Add a `"paramour"` script to `package.json`** — shorthand for `paramour generate`. Skipped if a `"paramour"` script already exists. 4. **Run the first generate** — writes the artifact and reports the route count. A project with no `app/` or `pages/` yet gets a warning and a skip, not an error. 5. **Install [agent skills](/docs/reference/next/cli/skills)** — when agent tooling is detected (`.agents/`, `.claude/`, `.codex/`, `.cursor/`, or a root `AGENTS.md` file, which counts as the `.agents/` target), the bundled skill is installed for each detected tool. Without any of those signals the step is skipped — init never invents an install location; `paramour skills` handles that with its portable-location fallback. 6. **Add a paramour section to `AGENTS.md`/`CLAUDE.md`** — a marker-delimited (`` … ``) section pointing agents at the installed skill and the generate/check/list verify loop. Append-only: the section goes into an existing `AGENTS.md` (or, failing that, `CLAUDE.md`) and the file is never created — a root `AGENTS.md` is a skills-detection signal, so init inventing one would change what step 5 sees on the next run. Re-runs refresh the content between the markers in place (everything outside them is never touched); a start marker without an end marker is left alone with a warning. It finishes with a warn-level setup summary (route directories, declared dependencies, tsconfig coverage of the artifact, agent-skills state when tooling is present) that never affects the exit code. ## Flags [#flags] | Flag | Default | Description | | ---------------- | ------- | ---------------------------------------------------------- | | `--dry-run` | off | Report every step without writing anything. | | `--force` | off | Overwrite an existing `paramour.config` with the scaffold. | | `--no-config` | off | Skip scaffolding `paramour.config.ts`. | | `--no-wrap` | off | Skip wrapping `next.config`. | | `--no-script` | off | Skip adding the `package.json` script. | | `--no-generate` | off | Skip the first generate. | | `--no-skills` | off | Skip installing agent skills. | | `--no-agents-md` | off | Skip the `AGENTS.md`/`CLAUDE.md` paramour section. | | `--help`, `-h` | — | Show usage. | The scaffold is always `paramour.config.ts`. If `--force` overwrites a project that had `paramour.config.mjs` or `.json`, the old file is deleted — the discovery order is ts-first, so leaving it behind would shadow nothing but confuse everyone. ## Exit codes [#exit-codes] | Code | When | | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `0` | Success — including skipped steps, dry runs, and manual-fallback wrap instructions. | | `2` | Hard errors only: no `package.json` at the current directory (run init at the project root), a broken `package.json`, an invalid existing config file, route collisions during the first generate. | There is no exit `1`: init verifies nothing, it sets up. --- # paramour list URL: https://paramour.dev/docs/reference/next/cli/list Prints every filesystem route, overlaid with the params/search shape of its route definition: ```bash npx paramour list ``` ```txt app routes (2): / app/route.def.ts /product/[id] app/product/[id]/route.def.ts params: id: integer search: page: integer (default: 1) q: string (optional) ``` The filesystem scan is authoritative for *which* routes exist (same engine as [`generate`](/docs/reference/next/cli/generate)). Shapes come from your `defineAppRoute`/`definePagesRoute` call sites: list scans source files for those calls and **evaluates the matching modules** to read the route objects, using the same `describeRoute` reflection that powers the devtools panel. That evaluation is why definitions should stay [import-safe](/docs/concepts/route-objects#convention-routedefts-beside-the-page) — a `route.def.ts` that imports server-only code will fail to load here. Modules that fail to load are reported and skipped. Set `routeFiles` globs in [`paramour.config`](/docs/reference/next/with-typed-routes#paramourconfig) to pin which modules are scanned when the automatic content scan misfires. ## Coverage warnings [#coverage-warnings] The output doubles as a coverage report, and all of these are warnings — they never change the exit code: * a filesystem route with no definition (`⚠ filesystem only (no route definition found)`); * a definition whose `(router, path)` matches no filesystem route (`definitions with no filesystem route:`); * duplicate definitions of one route (first wins); * modules that failed to evaluate. ## Flags [#flags] | Flag | Default | Description | | -------------------------- | ----------------------------------- | --------------------------------- | | `--app-dir ` | discovered `app/` or `src/app/` | App directory. | | `--pages-dir ` | discovered `pages/` or `src/pages/` | Pages directory. | | `--page-extensions ` | `tsx,ts,jsx,js` | Comma-separated, no leading dots. | | `--json` | off | Machine-readable output. | | `--help`, `-h` | — | Show usage. | `--json` emits the same data as one JSON object — `appRoutes` and `pagesRoutes` (each route carrying `definition: null` when none was found, never an absent key), plus `orphanDefinitions`, `duplicates`, and `loadFailures`. ## Exit codes [#exit-codes] | Code | When | | ---- | -------------------------------------------------------------------------- | | `0` | Report printed — warnings included. | | `2` | Usage/config errors, no route directories, route collisions, I/O failures. | --- # paramour skills URL: https://paramour.dev/docs/reference/next/cli/skills Agentic coding tools know nothing about paramour — it is too new to be in any model's training data. `@paramour-js/next` therefore ships an [Agent Skills](https://agentskills.io)–format skill inside the package itself (`SKILL.md` plus four reference files), and `paramour skills` installs it into your project so Claude Code, Cursor, Codex, and any other skills-reading tool can work with paramour correctly — and verify their own work with `paramour check`. Because the skill ships in the npm package, its content always matches the installed API version. The [AI agents guide](/docs/guides/ai-agents) covers the skill's contents and the surrounding agent story; this page documents the command. ```bash npx paramour skills ``` ```txt paramour skills ✔ .claude/skills/paramour — installed (5 files) ✔ .cursor/skills/paramour — installed (5 files) ``` The installer detects agent tooling by presence at the project root — the `.agents/`, `.claude/`, `.codex/`, and `.cursor/` directories, plus a root `AGENTS.md` file, which counts as detecting the `.agents/` target (so a repo with only an `AGENTS.md` gets `.agents/skills/paramour`) — and writes the skill into each detected tool's `skills/` directory. With nothing detected it installs to the portable `.agents/skills/` location and says so. Commit the installed files: they diff cleanly and version with your lockfile. ## Safe re-syncs [#safe-re-syncs] Every install writes a `.paramour-skills.json` manifest next to the skill recording the package version and a content hash per file. On a re-run (after upgrading `@paramour-js/next`, say): * files you never touched are updated in place, * files you edited locally are **left as-is** — re-run with `--force` to overwrite them, * files the package no longer ships are removed when untouched, kept (and untracked) when edited. A re-run that changes nothing writes nothing. Deleting the skill directory (or the whole tool directory) is a clean uninstall. ## Flags [#flags] | Flag | Default | Description | | -------------- | --------- | ---------------------------------------------------------------------------------------------------------- | | `--check` | off | Verify installed skills instead of writing; exit `1` when any copy is missing or stale. | | `--dry-run` | off | Report what would be written without writing. | | `--force` | off | Overwrite locally-modified skill files. | | `--json` | off | Machine-readable output. | | `--tool ` | detection | Target tool(s): `agents`, `claude`, `codex`, `cursor`. Repeatable or comma-separated; overrides detection. | | `--help`, `-h` | — | Show usage. | `--check` never writes, so combining it with `--dry-run` or `--force` is a usage error (exit `2`). ## `--check` in CI [#--check-in-ci] `skills --check` verifies that every detected target has an up-to-date copy — add it to CI next to `paramour check` if you want skill drift to fail builds after package upgrades. With no agent tooling detected there is nothing to verify, so the check passes — a project that never opted into skills can carry it in CI without a permanently red gate: ```bash npx paramour skills --check ``` Locally-modified files are reported as warnings but do not fail the check — tailoring the skill to your project is legitimate. Files that are missing or stale (the packaged content changed since the last sync) exit `1`. In a repo where only some detected tools should carry the skill (say `.cursor/` exists but only Claude skills are installed), scope the CI invocation with `--tool claude` so the unused tool does not read as missing. [`paramour doctor`](/docs/reference/next/cli/doctor) reports the same staleness as a warn-level check — advisory, never build-failing — and [`paramour init`](/docs/reference/next/cli/init) installs the skill automatically when it detects agent tooling (`--no-skills` opts out). ## Exit codes [#exit-codes] | Code | When | | ---- | -------------------------------------------------------------------------------------------------------- | | `0` | Install/re-sync succeeded — including when locally-modified files were skipped (the refusal is printed). | | `1` | `--check` only: an installed copy is missing or stale. | | `2` | Usage errors (unknown `--tool`, `--check` with a write flag), unreadable bundled content, I/O failures. |