paramour
Reference

@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-plugin

Requires ESLint 9 or 10 with flat config. There is no legacy-eslintrc preset.

Setup

Spread the recommended preset into eslint.config.js:

eslint.config.js
import paramour from "@paramour-js/eslint-plugin";

export default [
  // ...your other config
  paramour.configs.recommended,
];

Or wire the rules manually:

eslint.config.js
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:

  1. <Link href> — the href attribute of Link imported from next/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 called Link but comes from anywhere else never fires. The UrlObject form is covered too: href={{ pathname: "/foo" }} is flagged on its pathname.
  2. Router methods — the first argument of push, replace, and prefetch on a router obtained from useRouter() — imported from next/navigation or next/router — in both the variable form (const router = useRouter()) and the destructured form (const { push } = useRouter(), including renames like const { push: go } = useRouter()). A UrlObject first argument (router.push({ pathname: "/foo" })) is flagged the same way.
  3. The static Router form — push, replace, and prefetch on the next/router default export (import Router from "next/router"; Router.push("/x")).
  4. Server redirects — arguments to redirect and permanentRedirect imported from next/navigation. The server-side bypass, and the one most often forgotten.
  5. <Form action> — the action attribute of Form imported from next/form, when it is a string. Function values (server actions) are not paths and never fire.
  6. NextResponse.redirect / NextResponse.rewrite — with NextResponse imported from next/server, a raw internal path either as the direct first argument or as the first argument of an inline new URL("/path", base). These APIs require absolute URLs, so the message nudges new 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 toward

There 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

OptionTypeDefaultDescription
ignorePathsstring[][]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/" + id and 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")) that href() does not produce, so flagging it would fight correct code.
  • Member-expression forms — <UI.Link href> and ns.NextResponse.redirect(...) element/callee names are not resolved.
  • URL values built outside the call — const url = new URL("/x", base); NextResponse.redirect(url) escapes detection; only the inline new 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.project requirement, 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:

  1. useSearchParams() / useParams() — calls of either hook imported from next/navigation, under any local name, including the namespace form (nav.useParams()). Imports are tracked through scope resolution, so the same-named hooks from react-router-dom (or anywhere else) never fire. The message nudges the like-for-like replacement: useSearch(route) for useSearchParams(), useRouteParams(route) for useParams(), both from @paramour-js/next/app.
  2. router.query — .query access on a router obtained from next/router's useRouter() (the extensionful next/router.js spelling 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 is useRouteParams(route) and useSearch(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

OptionTypeDefaultDescription
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.params access in page.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 how no-raw-hrefs sequenced 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:

  1. + concatenation — href(route) + "?tab=1", including chained forms (href(route) + "#a" + x). The href import from paramour is tracked through scope resolution, aliased and namespace forms included.
  2. 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 legitimate

This 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 decode

The 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, and Date() called without new
  • Date.now(), Math.random(), performance.now()
  • crypto.randomUUID(), crypto.getRandomValues() — the global, or the default/namespace import of crypto / 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 — parseContext is 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.

On this page