paramour
Referenceparamour (core)

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), search (always optional — a codec map, or a rawSearch schema), and trailingSlash (optional boolean, default false).

import { ,  } from "paramour";

const  = ("/product/[id]", {
  : { : .() },
  : {
    : .().(1),
    : .().(),
  },
});

declare const : import("paramour").;
const { params,  } = await .();
const params: ParamsOutput<"/product/[id]", {
    readonly id: Codec<number, "required", false, "single", boolean>;
}>
.page;
page: number

The parse surface is three surfaces × throwing/safe, all async:

MethodAcceptsResolves to
parse(props)RoutePropsLike{ params, search }
parseParams(props)ParamsPropsLikethe decoded params only
parseSearch(props)SearchPropsLikethe decoded search only
safeParse(props)RoutePropsLikeSafeResult of both halves
safeParseParams(props)ParamsPropsLikeSafeResult<…, ParamsDecodeError> of the params
safeParseSearch(props)SearchPropsLikeSafeResult<…, SearchDecodeError> 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; the safe* twins return the same errors in a 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.

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.

trailingSlash

trailingSlash: true makes every link the route builds end in /. That is the URL shape a Next app with trailingSlash: true in next.config serves without a redirect (a static export writes asset/index.html for /asset/). The root stays /, and the query and fragment follow the slash:

import { , ,  } from "paramour";

const  = ("/asset", {
  : { : .(), : .().() },
  : true,
});

const  = (, { : { : "atk-guide" } });
// => "/asset/?name=atk-guide"

The option lives on the route definition, not in a Next config read, so core builds the same URL wherever the route module runs, including code with no Next present (a CLI printing absolute links with new URL(href(route), webRoot)). The path literal stays slash-free ("/asset", never "/asset/"): it is the route's identity in the registry and in the Href<"/asset"> brand, so the type of a built link is unchanged. The exact rule, including catch-alls and elision, is R6. Parsing is unaffected: params and search arrive from Next already split from the pathname. In component tests, <Link> drops the slash unless you set Next's flag for it; see Testing. paramour doctor warns when a definition disagrees with next.config. To set the option on every route, see Wrapping define*Route.

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:

MethodAcceptsReturns
parseContext(ctx)PagesContext{ params, search }
safeParseContext(ctx)PagesContextSafeResult 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,  } = .();
const params: ParamsOutput<"/legacy/[id]", {
    readonly id: Codec<number, "required", false, "single", boolean>;
}>
return { : { : ., : . ?? null } }; }) satisfies ;

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.)
  • 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 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

The status-discriminated result shape shared by the safe* route methods and the standalone safeDecodeParams / safeDecodeSearch helpers:

type SafeResult<T, E extends RouteDecodeError = RouteDecodeError> =
  | { data: T; status: "success" }
  | { error: E; status: "error" };

E narrows the error arm on surfaces that can only fail one way: safeParseParams, safeDecodeParams, and the params hooks return SafeResult<T, ParamsDecodeError>; safeParseSearch, safeDecodeSearch, and the search hooks return SafeResult<T, SearchDecodeError>. Only the full safeParse / safeParseContext keep the union.

One status check narrows both arms:

const  = await .();

if (. === "error") {
  ..issues;
issues: readonly Issue[]
} else { ..params;
params: ParamsOutput<"/product/[id]", {
    readonly id: Codec<number, "required", false, "single", boolean>;
}>
}

Only decode failures reach the error arm — a RouteDecodeError, the union of the two aggregate decode errors, or the one side a surface can produce. 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;
Property 'parse' does not exist on type 'PagesRoute<"/legacy/[id]", { readonly id: Codec<number, "required", false, "single", boolean>; }, Record<never, never>>'.
.parseContext;
Property 'parseContext' does not exist on type 'AppRoute<"/product/[id]", { readonly id: Codec<number, "required", false, "single", boolean>; }, Record<never, never>>'.

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, and not covered by semver; path is the one public data member. Reach for InferRouteParams / InferRouteSearch instead of reading a route's ~ members in types.

TypeWhat 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.
AnyRouteThe bound for helpers that take any route (R extends AnyRoute) — what href and the standalone decoders accept.
AnyAppRoute / AnyPagesRouteThe router-specific bounds — what the App and Pages hook modules accept, respectively.
InferRouteParams<R>The decoded params object type of a route (see below).
InferRouteSearch<R>The decoded search object type of a route — what parseSearch resolves to and useSearch returns.
RouteConfig<Path, PC, SC>The define*Route config parameter's type. With SearchSlot, what a generic wrapper around define*Route needs.

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 >;
type DocsParams = {
    slug: string[];
}

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:

TypeShape
ParamsConfig<Path>The params: config shape — one param codec per dynamic segment name in Path.
ParamsProps{ params? } — promised; annotate layouts with it. Layout props are assignable.
SearchProps{ searchParams? } — promised.
RoutePropsBoth members — annotate pages with it; Next's generated page props are assignable.
ParamsPropsLike / SearchPropsLike / RoutePropsLikeWhat the parse methods accept: the matching props type, or the same members as plain (already-awaited) objects.
PagesContext{ params?, query } — the contract of parseContext. query is required (getStaticProps contexts, which have none, fail to compose by design).

Wrapping define*Route

RouteConfig and SearchSlot are exported so a project can wrap the constructors without losing inference — mirror their three type parameters and pass the config through:

import {
  ,
  ,
  type ,
  type ,
  type ,
  type ,
} from "paramour";

export function <
  const  extends string,
  const  extends <> = <>,
  const  extends  = <never, never>,
>(: , : <, , >) {
  // ...project-specific bookkeeping here
  return (, );
}

const  = ("/product/[id]", {
  : { : .() },
  : { : .().() },
});

type ProductSearch = <typeof >;
type ProductSearch = {
    readonly tab: string | undefined;
}

When every route in a project shares an option, such as trailingSlash for a static export, a typed re-export is enough. Spread the config last so a route's own value still wins:

import {  as , ,  } from "paramour";

export const : typeof  = (, ) =>
  (, { : true, ... });

const  = ("/bundle", {
  : { : .(), : .() },
});

const  = (, { : { : "x", : "y" } });
// => "/bundle/?name=x&org=y"

Keep the name defineAppRoute (or definePagesRoute) for the re-export: paramour list and doctor find route modules by those names.

Explicit type arguments to defineAppRoute / definePagesRoute (and href) are not part of the supported API — their type parameters exist for inference and may change in a minor release.

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:

paramour-env.d.ts
// Generated by @paramour-js/next. Do not edit — regenerate with `paramour generate`.
import "paramour";

declare module "paramour" {
  interface ParamourRegister {
    : "/" | "/about" | "/product/[id]";
    : "/legacy/[id]";
  }
}
TypeResolves to
ParamourRegisterThe augmentation target itself — an empty interface codegen merges the per-router path unions into.
RegisteredAppRoutePathsThe union of registered App Router paths; string before generation.
RegisteredPagesRoutePathsThe Pages twin.
RegisteredStaticAppRoutePathsThe static-only subset of the App union — any [ marks a path dynamic.
RegisteredStaticPagesRoutePathsThe Pages twin.
RegisteredStaticRoutePathsEvery 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 = ;
type StaticPaths = "/" | "/about"

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.

On this page