Getting Started
Install paramour in a fresh Next.js app and build your first fully typed route, end to end.
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
Start from a fresh Next.js app (TypeScript and the App Router are the defaults):
npx create-next-app@latest my-app
cd my-appAlready have an App Router project? Skip ahead — nothing below assumes a clean slate except the route paths.
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 install paramour @paramour-js/nextInitialize
One command wires everything up:
npx paramour initparamour 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.What init just did
Four things, each idempotent (rerunning init never clobbers your edits):
- 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. - Wrapped
next.config.tswithwithTypedRoutes— the build-time integration that keeps the generated registry in sync whilenext devruns. - Added a
"paramour"script topackage.json— shorthand forparamour generate, which you'll run whenever you add or move a route. - 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 explains how it works.
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:
import { , } from "paramour";
export const = ("/product/[id]", {
: { : .() },
});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:
npx paramour generateThe payoff is immediate. href builds URLs from the route object — params are
required, typed, and serialized for you:
const link = (, { : { : 42 } });href returns a string subtype, so it drops straight into <Link href={...}>
or router.push(...) — no casts, no template literals, no [id] left in by
accident.
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:
// @filename: app/product/[id]/route.def.ts
import { , } from "paramour";
export const = ("/product/[id]", {
: { : .() },
});
// @filename: app/product/[id]/page.tsx
import type { RouteProps } from "paramour";
import { } from "./route.def";
export default async function (: RouteProps) {
const { params } = await .();
return <>Product #{.}</>;
}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 covers when to use which.
Add search params
Search params are declared on the same route object, with the same codecs. Extend the definition:
import { , } from "paramour";
export const = ("/product/[id]", {
: { : .() },
: {
: .().(1),
: .().(),
},
});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:
const search = await .();Link building understands search params too — and it's deliberate about the URL it produces:
const = (, {
: { : 42 },
: { : 1, : "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.
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):
// @filename: app/product/[id]/route.def.ts
import { , } from "paramour";
export const = ("/product/[id]", {
: { : .() },
: {
: .().(1),
: .().(),
},
});
// @filename: app/product/[id]/params-panel.tsx
"use client";
import { , } from "@paramour-js/next/app";
import { } from "./route.def";
export function () {
const = ();
const = ();
if (. === "error") {
return < ="alert">{..}</>;
}
return (
<>
Product #{..}
{/* ^? */}
{. === "success" && .. && (
<> — searching “{..}”</>
)}
</>
);
}Render it from the page (<ParamsPanel /> below the <h1>), 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 compares the two styles.
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 undernext dev. - In CI,
paramour checkverifies the committed artifact matches the filesystem — it never writes, and exits non-zero on drift:
npx paramour checkAdd it in front of your build and a stale registry becomes a failed build, not a runtime surprise:
{
"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:
// @filename: paramour-env.d.ts
import "paramour";
declare module "paramour" {
interface ParamourRegister {
: "/" | "/product/[id]";
}
}
// @filename: app/product/[id]/route.def.ts
import { , } from "paramour";
export const = ("/product/[productId]", { : { : .() },
});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
- Codecs & the type-state API — the full
p.*catalog and why illegal codec chains don't compile. - Route objects as currency — the design idea the whole library hangs off.
- Defining routes — recipes for catch-alls, multiple params, and safe parsing.
- CLI workflows —
generate,check,init,list, anddoctorin depth.