@paramour-js/eslint-plugin
The ESLint plugin — find every place paramour never sees, in both directions.
@paramour-js/eslint-plugin exists because paramour's value proposition —
validated params, typed path building, explicit serialization — evaporates
silently the moment someone writes <Link href="/users/123"> or
useSearchParams(). The code compiles, the page renders, and the route's
codecs simply never run. In a codebase mid-migration this is the default
failure mode, not an edge case. The plugin audits the bypass in both
directions — raw writes (no-raw-hrefs), raw reads
(no-raw-param-reads) — guards the integrity of what href() builds
(no-href-arithmetic), and catches two misuses of paramour's own API that
compile but misbehave (no-impure-value-defaults,
no-parse-context-in-get-static-props).
Install
pnpm add -D @paramour-js/eslint-pluginRequires ESLint 9 or 10 with flat config. There is no legacy-eslintrc preset.
Setup
Spread the recommended preset into eslint.config.js:
import paramour from "@paramour-js/eslint-plugin";
export default [
// ...your other config
paramour.configs.recommended,
];Or wire the rules manually:
import paramour from "@paramour-js/eslint-plugin";
export default [
{
plugins: { paramour },
rules: {
"paramour/no-href-arithmetic": "warn",
"paramour/no-impure-value-defaults": "warn",
"paramour/no-parse-context-in-get-static-props": "warn",
"paramour/no-raw-hrefs": "warn",
"paramour/no-raw-param-reads": "warn",
},
},
];The preset registers every rule at warn on purpose: a raw string href or
a raw param read is working code, and this is a nudge toward migration,
not a correctness gate. The two API-misuse rules are real bugs when they
fire, but they stay at warn too so the preset never fails a build on
upgrade. Once your routes are migrated, promote any rule individually:
rules: { "paramour/no-raw-hrefs": "error" }no-raw-hrefs
Reports string literals — and template literals with no ${} expressions —
that start with /, in six Next.js surfaces spanning both routers:
<Link href>— thehrefattribute ofLinkimported fromnext/link, under any local name. Imports are tracked through scope resolution, not name matching:import L from "next/link"fires on<L href="/x" />, while a component that happens to be calledLinkbut comes from anywhere else never fires. TheUrlObjectform is covered too:href={{ pathname: "/foo" }}is flagged on itspathname.- Router methods — the first argument of
push,replace, andprefetchon a router obtained fromuseRouter()— imported fromnext/navigationornext/router— in both the variable form (const router = useRouter()) and the destructured form (const { push } = useRouter(), including renames likeconst { push: go } = useRouter()). AUrlObjectfirst argument (router.push({ pathname: "/foo" })) is flagged the same way. - The static
Routerform —push,replace, andprefetchon thenext/routerdefault export (import Router from "next/router"; Router.push("/x")). - Server redirects — arguments to
redirectandpermanentRedirectimported fromnext/navigation. The server-side bypass, and the one most often forgotten. <Form action>— theactionattribute ofFormimported fromnext/form, when it is a string. Function values (server actions) are not paths and never fire.NextResponse.redirect/NextResponse.rewrite— withNextResponseimported fromnext/server, a raw internal path either as the direct first argument or as the first argument of an inlinenew URL("/path", base). These APIs require absolute URLs, so the message nudgesnew URL(href(route, …), request.url).
Everywhere, the extensionful nodenext spellings (next/link.js,
next/router.js, …) count too.
import Link from "next/link";
import { redirect, useRouter } from "next/navigation";
import { NextResponse } from "next/server";
import { href } from "paramour";
<Link href="/users/123" />; // ✗ flagged
<Link href={{ pathname: "/users/123" }} />; // ✗ flagged on the pathname
redirect("/login"); // ✗ flagged
const router = useRouter();
router.push("/shop?page=2"); // ✗ flagged
NextResponse.redirect(new URL("/login", request.url)); // ✗ flagged on "/login"
<Link href={href(usersRoute, { params: { id: 123 } })} />; // ✓ what the rule nudges towardThere is no autofix and no suggestion: a correct fix requires knowing
which route object to import, from where, and what params to pass — none
of it mechanically derivable from the string. The report message names the
href() alternative instead.
What is exempt
Anything that does not start with / is ignored — external URLs
(https://…), fragments (#…), mailto:/tel:, relative paths, and
empty strings all pass without a protocol allowlist to maintain.
Protocol-relative URLs (//cdn.example.com/x) are external too, and are
exempt despite their leading slashes.
Paths under /api (/api, /api/auth/signin, /api?x=1, but not
/apiary) are always exempt, with no configuration. They point at route
handlers, and href() cannot build those because the registry lists only
pages, so a warning there would have no fix. The exemption follows the
convention: /api is the Pages Router's reserved API directory and where
App Router projects put route handlers. Route handlers elsewhere need
ignorePaths, and a page that lives under /api is not checked.
Type-only imports (import type Link from "next/link") never fire — a
value usage of one is already a TypeScript error.
At the NextResponse surface, absolute-URL strings and URL-typed
variables are exempt — they are exactly what those APIs require. In the
UrlObject form, a computed key, a dynamic pathname value, or a
pathname arriving via spread never fires.
Options
| Option | Type | Default | Description |
|---|---|---|---|
ignorePaths | string[] | [] | Path prefixes to exempt during a migration. |
linkComponents | { name: string; source: string; prop?: string }[] | [] | Design-system Link wrappers to audit. |
ignorePaths is the escape hatch for incremental adoption: a project that
has migrated 10 of 80 routes silences the sections it has not reached yet
instead of drowning in warnings it cannot act on.
rules: {
"paramour/no-raw-hrefs": ["warn", { ignorePaths: ["/legacy", "/admin"] }],
}Matching is by path segment, not raw substring: "/legacy" exempts
/legacy, /legacy/old, /legacy?tab=1, and /legacy#top — but not
/legacybar. A trailing slash on the configured prefix is ignored
("/legacy/" behaves like "/legacy"), and "/" exempts everything.
Prefixes only — no globs. It applies identically at every surface,
including a UrlObject's pathname.
linkComponents teaches the rule your design-system Link wrappers,
which are otherwise undetectable syntactically:
rules: {
"paramour/no-raw-hrefs": ["warn", {
linkComponents: [
{ name: "AppLink", source: "@acme/ui" },
{ name: "default", source: "@acme/ui/button-link", prop: "to" },
],
}],
}name is the imported name — "default" for a default export —
never the local alias: import { AppLink as A } from "@acme/ui" still
fires on <A href="/x" />, the same scope-resolved matching every other
surface uses. prop defaults to "href". The UrlObject form is
checked on wrapper props too, and extensionful source spellings match.
Deliberately out of scope (v2)
Recorded so the omissions read as decisions, not oversights:
- Dynamic strings —
"/users/" + idand template literals with expressions are the riskiest hrefs but also the noisiest to flag; they need their own false-positive round before landing. revalidatePath— it legally takes route patterns (revalidatePath("/product/[id]", "page")) thathref()does not produce, so flagging it would fight correct code.- Member-expression forms —
<UI.Link href>andns.NextResponse.redirect(...)element/callee names are not resolved. URLvalues built outside the call —const url = new URL("/x", base); NextResponse.redirect(url)escapes detection; only the inlinenew URL(...)argument is inspected.- Routers crossing boundaries — a router instance passed as an
argument or through props escapes the same-scope initializer check. The
rule is purely syntactic by design: no type information, no
parserOptions.projectrequirement, works in any parser setup that produces JSX nodes.
no-raw-param-reads
The read-side twin of no-raw-hrefs: reports the Next.js read APIs whose
results paramour's codecs never validate, in two surfaces:
useSearchParams()/useParams()— calls of either hook imported fromnext/navigation, under any local name, including the namespace form (nav.useParams()). Imports are tracked through scope resolution, so the same-named hooks fromreact-router-dom(or anywhere else) never fire. The message nudges the like-for-like replacement:useSearch(route)foruseSearchParams(),useRouteParams(route)foruseParams(), both from@paramour-js/next/app.router.query—.queryaccess on a router obtained fromnext/router'suseRouter()(the extensionfulnext/router.jsspelling counts too), in the variable form (const router = useRouter()), the direct form (useRouter().query), and the destructured form (const { query } = useRouter(), including renames and nested patterns). The nudge isuseRouteParams(route)anduseSearch(route)from@paramour-js/next/pages.
import { useParams, useSearchParams } from "next/navigation";
import { useRouteParams, useSearch } from "@paramour-js/next/app";
const params = useParams(); // ✗ flagged
const search = useSearchParams(); // ✗ flagged
const result = useRouteParams(productRoute); // ✓ what the rule nudges toward
const query = useSearch(productRoute); // ✓There is no autofix and no suggestion: a correct fix requires knowing which route object the component belongs to — not mechanically derivable from the call site.
What is exempt
Only reads through the tracked imports fire. useParams from
react-router-dom, a locally defined useParams, and type-only imports
never fire. useRouter().query where useRouter comes from
next/navigation never fires either — the App Router's router has no
query. Computed access (router["query"]) and a router crossing a
function or prop boundary escape detection — the rule is purely
syntactic, same as no-raw-hrefs.
Other next/navigation reads (usePathname, useSelectedLayoutSegment)
are not paramour's territory and are never flagged.
Options
| Option | Type | Default | Description |
|---|---|---|---|
allow | ("routerQuery" | "useParams" | "useSearchParams")[] | [] | Surfaces to switch off wholesale. |
allow is the escape hatch for a codebase that deliberately keeps one
surface untyped — for example an app that reads useParams() only in a
handful of not-yet-migrated components:
rules: {
"paramour/no-raw-param-reads": ["warn", { allow: ["useParams"] }],
}For a single legitimate raw read — say, forwarding ad-hoc utm_*
params the app deliberately leaves out of its route definitions — prefer
a targeted disable comment over switching off the whole surface:
// eslint-disable-next-line paramour/no-raw-param-reads -- untyped utm_* forwarding
const search = useSearchParams();Deliberately out of scope (v1)
Recorded so the omissions read as decisions, not oversights:
- Direct
props.searchParams/props.paramsaccess inpage.tsx/layout.tsx— the noisiest surface and the only one needing filename awareness; it follows once the hook surfaces prove the false-positive policy, mirroring howno-raw-hrefssequenced dynamic strings out of its v1. - Routers crossing boundaries — same accepted cost as
no-raw-hrefs. - Computed access —
router["query"]is invisible to a syntactic rule.
no-href-arithmetic
Reports string arithmetic that appends content after an href() result
— the exact thing explicit serialization exists to prevent — in two
surfaces:
+concatenation —href(route) + "?tab=1", including chained forms (href(route) + "#a" + x). Thehrefimport fromparamouris tracked through scope resolution, aliased and namespace forms included.- Template literals — content after
${href(route)}:`${href(route)}?page=2`,`${href(route)}/${child}`.
Prepending is fine: origin + href(route) and
`${origin}${href(route)}` are the legitimate way to build absolute
URLs for metadata, emails, and redirects, and never fire.
import { href } from "paramour";
const a = href(route) + "?tab=1"; // ✗ flagged — declare tab in the route's search codecs
const b = `${href(route)}/reviews`; // ✗ flagged
const c = href(route) + "#reviews"; // ✗ flagged, autofixed ↓
const fixed = href(route, { hash: "reviews" }); // ✓ what the autofix produces
const url = "https://example.com" + href(route); // ✓ prefixing is legitimateThis is the plugin's first fixable rule, and the fixer is deliberately
narrow: it rewrites only the pure hash case — a single appended
"#fragment" literal on a call whose options provably have no hash
(absent, or an object literal without one). The rewrite is exactly
semantics-preserving: href() renders hash as a trailing #fragment,
byte-identical to the concatenation it replaces. ? suffixes are never
autofixed — the appended query needs a codec key in the route's search
config, which no fixer can invent — so the message points at the search
option instead.
What is exempt
Prefix-only concatenation and interpolation, as above. href from any
other module, shadowed locals, and type-only imports never fire. Tagged
templates (sql`${href(route)}#x`) are skipped — the tag's semantics
are unknown.
Options
No options in v1. One-off exceptions use a standard
// eslint-disable-next-line paramour/no-href-arithmetic comment.
Deliberately out of scope (v1)
Recorded so the omissions read as decisions, not oversights:
- Method-style flows —
href(route).concat("#a"),[href(route), "#a"].join(""), and+=accumulation. - Values crossing bindings —
const base = href(route); base + "#a"escapes detection; same purely-syntactic boundary as the other rules. - The prefix-template autofix —
`${origin}${href(route)}#top`gets the hash message but no fix; the fixer only rewrites expressions that are the call plus a suffix. - A
route.href()method form — not an omission but a fact: paramour has no such method.href()is a standalone import, which is what makes the scope-resolved detection precise.
no-impure-value-defaults
Reports a codec's value-form .default() or .catch() whose argument
reads the clock or a random source. A value argument is evaluated once,
when the route module loads, so p.timestamp().default(new Date())
freezes module-load time forever — and because value defaults drive
URL elision, href() then elides against that stale value. The fix is
the factory form, which runs per decode:
import { p } from "paramour";
const since = p.timestamp().default(new Date()); // ✗ frozen at module load
const seed = p.number().catch(Math.random()); // ✗
const fresh = p.timestamp().default(() => new Date()); // ✓ fresh per decodeThe impure constructs are a fixed allowlist, matched anywhere inside the
argument (so Date.now() - DAY and new Date().toISOString() count) but
never inside a nested function:
new Date()with no arguments, andDate()called withoutnewDate.now(),Math.random(),performance.now()crypto.randomUUID(),crypto.getRandomValues()— the global, or the default/namespace import ofcrypto/node:crypto- any
Temporal.Now.*()call
The rule only fires on a modifier whose receiver chain starts at a
p.<builder>() call with p imported from paramour (named, aliased,
or ns.p through a namespace import), walking back through intermediate
modifiers such as .catch(…).default(…). Other libraries' .default()
(zod, yup, your own builders) never fire.
There is no autofix, only an editor suggestion that wraps the
argument in () => …. The rewrite deliberately changes behavior, so
--fix never applies it: factory defaults are never elided from URLs (a
time-varying factory could elide an explicitly passed value that later
decodes differently), and @paramour-js/nuqs types factory-defaulted
keys as nullable, so downstream reads may need adjusting.
What is exempt
Factory forms, pure conversions (new Date("2020-01-01T00:00:00Z"),
new Date(0)), shadowed globals (a local Date or Math, a crypto
imported from anywhere but node's crypto module), and .default() /
.catch() on anything that is not a paramour codec chain, including
Promise#catch.
Options
No options in v1. One-off exceptions use a standard
// eslint-disable-next-line paramour/no-impure-value-defaults comment.
Deliberately out of scope (v1)
Recorded so the omissions read as decisions, not oversights:
- A purity analysis — arbitrary calls (
now(),getConfig()) are not flagged; only the allowlist above, which is impure by definition. A user-extendable list may come later. - Codecs crossing bindings —
const c = p.timestamp(); c.default(new Date())escapes detection, the same purely syntactic boundary as the other rules. - Shared mutable defaults —
p.json(s).default({})hands out the same object on every decode; that is a reference-sharing concern, not a frozen-time one, and arrays are already copied per call.
no-parse-context-in-get-static-props
Reports route.parseContext(ctx) and route.safeParseContext(ctx)
inside getStaticProps. A getStaticProps context carries no query
string, so the parse always fails there — by design, since typed search
at build time would be a lie. The static story is decoding just the path
params:
import { safeDecodeParams } from "paramour";
export async function getStaticProps(ctx) {
const bad = productRoute.parseContext(ctx); // ✗ always fails
const good = safeDecodeParams(productRoute, ctx.params ?? {}); // ✓
}The message names the matching replacement: decodeParams for
parseContext, safeDecodeParams for safeParseContext.
TypeScript already rejects this call when ctx is typed as
GetStaticPropsContext (PagesContext requires query); the rule
covers plain-JS pages and contexts that reach the call as any.
getStaticProps is recognized as a module-level function declaration or
a const initialized with a function (through as, satisfies, and
non-null wrappers), exported or not — export { getStaticProps } after
the declaration counts. Every call lexically inside it is checked,
including nested helpers.
What is exempt
getServerSideProps and getInitialProps, the supported homes for
parseContext; calls elsewhere in the same file; and non-module-level
functions that happen to be named getStaticProps.
Options
No options in v1.
Deliberately out of scope (v1)
getStaticPaths—parseContextis equally wrong there, but the misuse is rare enough that it isn't worth a broader rule name.- Wrapped exports —
export const getStaticProps = withI18n(async (ctx) => …)hides the function inside a call and is not recognized. - Computed access —
route["parseContext"](ctx)is not matched.