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 understrict: 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 concurrentparamour generate --watchfrom fighting over the file.- every other phase (lint, info, …) — pass-through; no generation.
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.
| Option | Default | Description |
|---|---|---|
strict | false | Upgrade 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.
import type { ParamourConfig } from "@paramour-js/next";
export default {
: ["tsx", "ts"],
: ["app/**/route.def.ts"],
} satisfies ParamourConfig;| Field | Default | Description |
|---|---|---|
appDir | discovered app/ or src/app/ | App directory, relative to the project root. |
pagesDir | discovered pages/ or src/pages/ | Pages directory, relative to the project root. |
outFile | paramour-env.d.ts | Artifact 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. |
routeFiles | automatic content scan | Globs (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.tsxandpages/pricing.tsxboth 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 —
/docsbeside/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 pathWhere 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.
withTypedRoutesrethrows 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 devorparamour 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.