Every new React project used to start the same way. I would open the last project, copy its eslint.config file, copy the matching devDependencies out of its package.json, run install, and then reconcile whatever had drifted since the last time. The config was good. Keeping every copy of it in sync was not.
Here is how a project starts now:
import gaiaLint from '@gaia-react/lint';
import {defineConfig} from 'eslint/config';
export default defineConfig([
...gaiaLint.ignores({gitignore: '.gitignore'}),
...gaiaLint.base,
...gaiaLint.react,
...gaiaLint.testing,
...gaiaLint.storybook,
...gaiaLint.playwright,
...gaiaLint.styleHygiene,
...gaiaLint.guardrails,
...gaiaLint.betterTailwind({
entryPoint: './app/styles/tailwind.css',
ignore: ['plain-link', 'plain-table'],
}),
...gaiaLint.prettier,
]);That is the entire ESLint configuration for a production React app. One dependency, a list of presets. When I improve a rule, every project picks it up on the next install.
This post is about why I moved my ESLint setup into a package, what is in it, and how to make it your own when you disagree with me.
Why a copied config rots
A serious ESLint setup for React and TypeScript pulls in close to twenty plugins: React, hooks, accessibility, imports, testing, a sorter, a naming-convention checker, the TypeScript rules, and more. Each one versions on its own schedule. Each one occasionally changes a rule default or a recommended set.
Copy that config into five projects and you now have five things to maintain. In practice you do not maintain five. You maintain the one you opened most recently, and the rest fall behind. The newest project has the rule you just discovered; the eighteen-month-old project does not. None of this is carelessness. It is what shared configuration does when the sharing is copy and paste.
The ESLint 8 to 9 migration made the cost obvious. Flat config was a real change in shape, not a version bump. With a copied config, that migration is a separate piece of work in every repository. With one package, it happens once, behind a version number.
Install and compose
pnpm add -D @gaia-react/lint eslint prettier typescripteslint, prettier, and typescript are peer dependencies, so your project owns those versions. Every plugin the config needs ships inside the package, so there is no second list of devDependencies to keep in step.
The config is not one object. It is a set of presets you spread in order:
base,react,styleHygiene, andguardrailsare the core: plain JavaScript and TypeScript rules, the React and hooks and accessibility rules, import hygiene, sorting, and a set of opinionated guardrails I will get to below.testing,storybook, andplaywrightare optional and scoped. You spreadplaywrightonly if you use Playwright, and its rules apply only to your end-to-end folder.betterTailwindandignoresare factories, because they need an argument.betterTailwindneeds to know where your Tailwind entry CSS lives;ignoresreads your.gitignoreso ESLint skips the same files Git does.prettiercomes last, on purpose. Flat config is last-write-wins, and this preset turns off every formatting rule so Prettier and ESLint stop fighting.
Prettier and Stylelint configs come from the same package, through subpath exports:
// prettier.config.mjs
export {default} from '@gaia-react/lint/prettier';One package, one version number, for all three tools. It is MIT licensed, and the source and the full rule list are on GitHub.
The opinions in the box
The package describes itself as opinionated, and it means it. A few of the rules in guardrails and base will not be to everyone's taste. Here are the ones worth explaining.
No enum. TypeScript enums emit runtime code, are not tree-shakeable, and blur the line between value space and type space. Numeric and string enums behave differently, and reverse mappings surprise people. A const object with a derived union type does the same job with none of that:
const Status = {
Active: 'active',
Inactive: 'inactive',
} as const;
type Status = (typeof Status)[keyof typeof Status];No switch. A switch is a lookup map, an early-return chain, or an exhaustiveness check wearing a disguise, and it brings fallthrough bugs and missing-default bugs along for free. Each of the honest forms is clearer than the thing it replaces.
Arrow functions only. React is functional. Arrow functions do not hoist, so call order is explicit and you always know the definition sits above the line you are reading. They have no this, which you do not want in React anyway. They are consistent with the function expressions you already pass to map and filter. The rule even closes an upstream gap: the underlying plugin quietly exempts export default function Named() {}, so the config adds its own check to catch that one.
Alphanumeric everything. Imports, JSX props, object keys, and union members are sorted automatically. Sort order is the kind of thing engineers can debate forever and never settle, so the config does not ask. A to Z, fixed on save, the same in every file.
These are not bug-catching rules. They are consistency rules. They exist so that every file in the project, and every project, reads the same way.
When you disagree
An opinionated package has an obvious cost: you have inherited my opinions. If you want switch, it is now a rule you have to turn off rather than a rule you simply never added.
The answer is the same last-write-wins behavior that puts prettier at the end. Append an override block after the gaia-lint spreads:
export default defineConfig([
...gaiaLint.base,
...gaiaLint.react,
// your project, your call
{rules: {'no-switch/no-switch': 'off'}},
]);Scope it to a glob if you only want the exception in one place:
{
files: ['app/legacy/**'],
rules: {'sonarjs/cognitive-complexity': 'off'},
}That is the deal the package offers. You start strict and loosen on purpose, with the exception written down in your config where the next person can see it. The alternative, starting from an empty config and meaning to get strict later, is a plan that rarely survives contact with a deadline.
Why this matters more with AI in the loop
I wrote recently about the quality bar I set before letting Claude write any code: every rule an error, zero warnings, the gates up before the first prompt.
A packaged config is what makes that bar hold across more than one project. AI joins a codebase fresh every session. It does not carry a sense of house style between projects the way a long-tenured engineer does. What it has is the config and the errors the config produces. When that config is one versioned package instead of a handful of drifting copies, "what good looks like" is identical in every repository I work in. The standard is not in my head, and it is not in a wiki. It is installed.
That is the real reason I stopped copy-pasting. Not the time saved per project. It is that the best version of my config is now the only version, and every project, and everything writing code in it, gets exactly that.
Pre-publish checklist
Gates from branding/voice/blog.md ("Before publish") and branding/VOICE.md.
- Em-dash check:
grep "—"on the body returns nothing - Opener is a hook (the copy-paste ritual, then the 10-line config), not a defensive self-critique
- First person throughout
- Teaches something concrete: install, compose, override
- Honest about tradeoffs: the "you inherited my opinions" section names the cost
- Fragments are deliberate punches only
- Parallel triplets: one per post at most
- No AI tells, borrowed-field jargon, or empty hype adjectives
- AI framed as an expert that is not human; no junior-dev framing
- No contempt for engineers with drifting configs; framed as what copy-paste does, not user error
- Every code sample traces to a real source (package README / package.json / changelog)
- "Template" never refers to GAIA
- All code blocks render with correct language highlighting
- Verify
@gaia-react/lintexamples against the published package (currently npm v1.1.3) - Cross-link to "Zero warnings": confirm the final slug and that the post is live before publishing
- Word count on target (~1,200 words)
Sources used
gaia-react/lint/README.md: package API, preset list, override patterns,no-enum/no-switchrationale, subpath exportsgaia-react/lint/package.json: plugin set, peer dependencies, current versiongaia-react/lint/CHANGELOG.md(1.1.1): theprefer-arrow-functionsnamed-default-export gap and the customno-restricted-syntaxfix- react-japan.dev ESLint series being retired (
eslint-sorting,eslint-opinionated-rules): arrow-function and alphanumeric-sorting rationale, carried over from Steven's prior writing studio/branding/VOICE.md+branding/voice/blog.md: voice gates