:::tldr I used to copy my ESLint config into every new React project, and every copy quietly rotted in place. Moving it into a single shared package fixed that. Now every project, and every AI session writing code in one of them, runs against the same versioned standard. :::
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';
const lint = gaiaLint();
export default defineConfig([
...lint.ignores,
...lint.base,
...lint.react,
...lint.testing,
...lint.storybook,
...lint.playwright,
...lint.styleHygiene,
...lint.guardrails,
...lint.betterTailwind({entryPoint: './app/styles/tailwind.css'}),
...lint.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 if you prefer different settings for any of the rules.
Why a copied config rots
A robust ESLint setup for React and TypeScript pulls in a lot of plugins and presets: React, hooks, accessibility, imports, testing, sorting, a naming-convention checker, 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 helpful 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 merely 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
npm 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:
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.
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.
No IIFEs in JSX. Wrapping a block of logic in {(() => { ... })()} inside a JSX expression container hides intent and allocates a fresh function on every render. Compute the value in a variable above the return, or use an inline && expression instead. The rule is scoped to .tsx and .jsx files.
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.
Customizing rules for your project
An opinionated package picks a default for every rule. To tailor the rules to your project, you can turn the rule off, scope it to a folder, or change a setting. If you want to allow switch, it is now a rule you 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([
...lint.base,
...lint.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 will write more in a future article 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.
This 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.