# 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:
{params.error.message}
; } return (Product #{params.data.id} {/* ^? */} {search.status === "success" && search.data.q && ( <> — searching “{search.data.q}”> )}
); } ``` Render it from the page (`{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": returnLoading…
; case "success": returnProduct #{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 }) { returnBad 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(Loading…
; if (isError) returnBad search params: {error.message}
; returnq={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}
; } returnq={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. = 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 {params.error.message} Product #{params.data.id} {search.error.message}
Page {search.data.page}
{search.data.q && <> — “{search.data.q}”>}
{params.error.message} Loading… Product #{params.data.id} {search.error.message} Page {search.data.page}` | 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. |
| `StandardSearchSchemaProduct #{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 useSearchResults — 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` | `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
` | `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
` | `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