paramour
Reference@paramour-js/next

withTypedRoutes

The Next config wrapper — plus WithTypedRoutesOptions, ParamourConfig, and RouteCollisionError.

Everything on this page imports from the package's main entry, @paramour-js/next. These four exports are that entry's entire public surface.

withTypedRoutes

Wraps a Next config so the registry artifact (paramour-env.d.ts) is generated and kept current by next dev and validated by next build.

function withTypedRoutes<C extends object>(
  config: C | ((phase: string, ctx: unknown) => C | Promise<C>),
  options?: WithTypedRoutesOptions,
): (phase: string, ctx: unknown) => C | Promise<C>;

It accepts either config form — a plain object or the (phase, ctx) => config function, possibly async — and always returns the function form. Next's phase argument selects the behavior:

  • next build — one generation pass before the config is returned, so the build type-checks against fresh routes. If the pass changed the file (drift, or a missing artifact), a loud warning names the routes that appeared and disappeared — or the build fails under strict: true.
  • next dev — one immediate generation pass, then a debounced watcher that regenerates as route directories change. Two single-writer guards (an in-process singleton and a cross-process pidfile lock) keep repeat config evaluations and a concurrent paramour generate --watch from fighting over the file.
  • every other phase (lint, info, …) — pass-through; no generation.
next.config.ts
import type { NextConfig } from "next";

import {  } from "@paramour-js/next";

const : NextConfig = {
  : true,
};

export default (, { : true });

The wrapper reads exactly one field off the resolved config — pageExtensions, so custom extensions scan correctly — and passes everything else through untouched. Because it returns the function form, it composes with other wrappers by going outermost:

export default withTypedRoutes(withMDX(nextConfig), { strict: true });

Generation is never load-bearing for your app: an incidental generation failure (transient I/O, an unreadable directory) logs a warning and lets next dev/next build continue with stale route types. Two states are the deliberate exceptions and throw during config evaluation, because Next itself has no valid build for them:

  • a RouteCollisionError — two files resolving to one URL;
  • a populated route directory that Next is configured to ignore (a config error discovery refuses to paper over).

If no route directory exists at all (app/, pages/, src/app/, src/pages/), the wrapper warns once and skips generation entirely.

One config file for the CLI and the wrapper

withTypedRoutes reads paramour.config.ts exactly as the CLI does — appDir, pagesDir, and outFile all apply to next dev/next build too, so the two writers can never produce different artifacts. The one field Next owns is pageExtensions: inside Next, the wrapped config's pageExtensions wins (it decides what is a page), and the wrapper warns once if the config file lists different ones. A malformed config file throws during config evaluation — it's a configuration mistake, not an incidental generation failure.

WithTypedRoutesOptions

The optional second argument to withTypedRoutes. Where routes and the artifact live is configured in paramour.config.ts, not here.

OptionDefaultDescription
strictfalseUpgrade build-phase drift from a loud warning to a build failure.

strict: true is for teams that treat the committed artifact as the law: if a next build finds the artifact stale — or missing, which counts as drift — the build fails instead of warning. The file is regenerated first, then the error is thrown, so the fix is always "commit the corrected file". The default is false, which stays friendly to gitignored-artifact workflows and CI images that generate on the fly. This docs site runs strict: true; the CLI guide compares it with the paramour check gate.

Only drift can fail a strict build. An incidental generation failure still degrades to stale types with a warning, strict or not. Line endings are not drift either: a Windows checkout with core.autocrlf=true leaves the artifact CRLF on disk, which is treated as current and left untouched.

ParamourConfig

The shape of paramour.config.ts (or .mjs/.json) at the project root — read by both the CLI and withTypedRoutes, and exported here so you can type the default export. Every field is optional; CLI precedence is flags → this file → automatic discovery.

paramour.config.ts
import type { ParamourConfig } from "@paramour-js/next";

export default {
  : ["tsx", "ts"],
  : ["app/**/route.def.ts"],
} satisfies ParamourConfig;
FieldDefaultDescription
appDirdiscovered app/ or src/app/App directory, relative to the project root.
pagesDirdiscovered pages/ or src/pages/Pages directory, relative to the project root.
outFileparamour-env.d.tsArtifact path, relative to the project root — the escape hatch for monorepos where the Next app root isn't where the file should live.
pageExtensions["tsx", "ts", "jsx", "js"]Page extensions, no leading dots — a leading dot is rejected because it would silently match nothing. Keep it in step with next.config's: inside Next, Next's list wins.
routeFilesautomatic content scanGlobs (relative to the project root) of modules exporting route definitions. Read by list/doctor only — generation never uses it. Set it when the automatic defineAppRoute/definePagesRoute source scan misfires.

The config file is discovered at the project root only (no upward traversal), first match wins: paramour.config.ts, then paramour.config.mjs, then paramour.config.json. .ts/.mjs files default-export the object. Validation is strict — an unknown key (say, a pagesExtensions typo) is an error, mapped to CLI exit 2, not silently ignored.

RouteCollisionError

Thrown when two files resolve to one URL — states Next itself refuses to build, so no valid artifact exists and the scanners throw instead of emitting one. It's a plain Error subclass with name: "RouteCollisionError".

Three shapes trigger it, checked within each router and across the two:

  • Same path from both routers — app/pricing/page.tsx and pages/pricing.tsx both claim /pricing.
  • Different slug names at one position — /x/[id] beside /x/[slug] ("You cannot use different slug names for the same dynamic path"). This includes [...a] beside [[...a]] at the same level; [id] beside [...slug] is not a collision — that's Next's documented priority pattern.
  • Optional catch-all vs. its base path — /docs beside /docs/[[...slug]]: an optional catch-all also matches its own base path.
route collision: "/x/[id]" (app) and "/x/[slug]" (app) declare conflicting
dynamic segments ([id] vs [slug]) at the same position — Next refuses
different slug names for the same dynamic path

Where you see it depends on the entry point, but the fix is the same: rename or remove one of the conflicting files, matching what Next itself would demand.

  • withTypedRoutes rethrows it from config evaluation, dev and build phases alike — naming the actual problem instead of leaving a stale artifact to confuse the type errors that follow.
  • The CLI maps it to exit 2 (an operational error, not drift).
  • The one non-fatal path is a running watcher (next dev or paramour generate --watch): a mid-watch collision is usually a file mid-move, so it logs loudly on every rescan, keeps the last good artifact on disk, and keeps watching for the fix.

On this page