paramour
Getting Started

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-app

Already 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/next

Initialize

One command wires everything up:

npx paramour init
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.

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 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:

app/product/[id]/route.def.ts
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 generate

The payoff is immediate. href builds URLs from the route object — params are required, typed, and serialized for you:

const link = (, { : { : 42 } });
const link: Href<"/product/[id]">

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:

app/product/[id]/page.tsx
// @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 .();
const params: ParamsOutput<"/product/[id]", {
    readonly id: Codec<number, "required", false, "single", boolean>;
}>
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:

app/product/[id]/route.def.ts
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 .();
const search: InferSearchOutput<{
    readonly page: Codec<number, "defaulted", false, "single", true>;
    readonly q: Codec<string, "optional", false, "single", boolean>;
}>

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):

app/product/[id]/params-panel.tsx
// @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 under next dev.
  • In CI, paramour check verifies the committed artifact matches the filesystem — it never writes, and exits non-zero on drift:
npx paramour check

Add it in front of your build and a stale registry becomes a failed build, not a runtime surprise:

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:

// @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]", {
Argument of type '"/product/[productId]"' is not assignable to parameter of type '"/" | "/product/[id]"'.
: { : .() }, });

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

On this page