defineAppRoute & definePagesRoute
The route constructors, their parse surfaces, and the route and registry types.
The route constructors take a URL-shaped path literal plus codec configs and return a plain route object — 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
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 schema).
import { , } from "paramour";
const = ("/product/[id]", {
: { : .() },
: {
: .().(1),
: .().(),
},
});
declare const : import("paramour").;
const { params, } = await .();
.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
searchParamspromise 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
parsethrows before search is decoded. - The throwing methods throw
ParamsDecodeError/SearchDecodeError; thesafe*twins return the same errors in aSafeResultinstead. - 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.
Next control-flow errors pass through
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
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 for catch-all
recipes.
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 |
// @filename: lib/routes.ts
import { , } from "paramour";
export const = ("/legacy/[id]", {
: { : .() },
: { : .().() },
});
// @filename: pages/legacy/[id].tsx
import type { } from "next";
import { } from "../../lib/routes";
export const = (async () => {
const { params, } = .(); return { : { : ., : . ?? null } };
}) satisfies ;How the context splits:
ctx.paramsis authoritative for path params when present. When it's absent (getInitialProps—NextPageContexthas noparams), path params are extracted fromqueryby segment name, which is sound because Next's own merge gives route params precedence inquery.- Search is always
queryminus 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 — seedecodeParams.) - Params decode first — the same morally-a-404 rule as the App surface.
Two contract edges worth knowing:
getStaticPropsis out of scope by design. Its context carries no query string, soparseContextrejects it loudly rather than pretending typed search exists at build time. The static-generation story isdecodeParams/safeDecodeParamsagainstctx.params.- A search key can't shadow a path param.
router.querymerges 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
The status-discriminated result shape shared by the safe* route methods
and the standalone safeDecodeParams / safeDecodeSearch helpers:
type SafeResult<T> =
| { data: T; status: "success" }
| { error: RouteDecodeError; status: "error" };One status check narrows both arms:
const = await .();
if (. === "error") {
..issues;} else {
..params;}Only decode failures reach the error arm — its type is
RouteDecodeError, 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
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:
import { , , } from "paramour";
const = ("/product/[id]", {
: { : .() },
});
const = ("/legacy/[id]", {
: { : .() },
});
.parse;
.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
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<Path, PC, SC> | The router-agnostic core every route extends: the path literal plus the codec configs. |
AppRoute<Path, PC, SC> | Route plus the async props-based parse surface. |
PagesRoute<Path, PC, SC> | 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<R> | 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 []):
import { , , type } from "paramour";
const = ("/docs/[[...slug]]", {
: { : .() },
});
type DocsParams = <typeof >;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<Path> | 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
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.
The generated artifact is a pure types-only augmentation — no runtime import, nothing in your bundle:
// Generated by @paramour-js/next. Do not edit — regenerate with `paramour generate`.
import "paramour";
declare module "paramour" {
interface ParamourRegister {
: "/" | "/about" | "/product/[id]";
: "/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:
// @filename: paramour-env.d.ts
import "paramour";
declare module "paramour" {
interface ParamourRegister {
: "/" | "/about" | "/product/[id]";
}
}
// @filename: demo.ts
import type { } from "paramour";
type StaticPaths = ;Two subtleties:
- Per-router independence. Each per-router union falls back to
stringindependently — an empty scan for one router never erases verification for the other. For that same reason,RegisteredStaticRoutePathsis deliberately not the union of the two per-router static types (an absent side'sstringwould swallow the union); it degrades tostringonly 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.