GAIA's lint configuration.
pnpm add -D @gaia-react/lint eslint prettier typescriptThe block below is the actual eslint.config.mjs shipped by GAIA. Copy it
verbatim and adjust the override block at the bottom for your project.
import gaiaLint from '@gaia-react/lint';
import {defineConfig} from 'eslint/config';
const lint = gaiaLint();
export default defineConfig([
...lint.ignores,
...lint.base,
...lint.react,
...lint.reactRouter,
...lint.testing,
...lint.storybook,
...lint.playwright,
...lint.styleHygiene,
...lint.guardrails,
...lint.betterTailwind({entryPoint: './app/styles/tailwind.css'}),
...lint.prettier,
]);Drop the reactRouter line if you are not on React Router framework mode.
See Router-specific rules.
gaiaLint(opts?) returns a bundle of config blocks. Call it once at the
top of eslint.config.mjs and spread the returned configs into
defineConfig.
| Option | Type | Default | Description |
|---|---|---|---|
sourceDir |
string |
'app' |
Project source directory (relative to repo root). Used to scope filename conventions, hook-folder rules, and the no-relative-import-paths root path. |
Non-GAIA projects that store source under src/ (or any other path):
const lint = gaiaLint({sourceDir: 'src'});That single call rebinds base, styleHygiene, and guardrails to the
new source root — no per-config override blocks needed.
| Property | Shape | Includes | Required? |
|---|---|---|---|
base |
Linter.Config[] |
JS recommended, TypeScript (typescript-eslint), import-x, eslint-comments, prefer-arrow-functions, lodash/underscore guard, no-restricted-imports (bans bare @conform-to/zod) |
required |
react |
Linter.Config[] |
eslint-plugin-react, react-hooks, jsx-a11y, GAIA-specific React rules |
required for React apps |
reactRouter |
Linter.Config[] |
Relaxations for React Router framework mode. Spread after react. See Router-specific rules |
React Router only |
styleHygiene |
Linter.Config[] |
canonical, perfectionist, unicorn, unused-imports, check-file |
required |
guardrails |
Linter.Config[] |
no-enum (custom), no-switch (custom), no-jsx-iife (custom), no-null-render (custom), no-zod-enum (custom), no-relative-import-paths, sonarjs, eslint-comments, import-x, prefer-arrow-functions |
required |
testing |
Linter.Config[] |
Vitest + Testing Library config scoped to *.test.* and test/ |
optional |
storybook |
Linter.Config[] |
eslint-plugin-storybook scoped to *.stories.* and .storybook/main.* |
optional |
playwright |
Linter.Config[] |
eslint-plugin-playwright scoped to .playwright/ |
optional |
prettier |
Linter.Config[] |
eslint-config-prettier — must be last to disable formatting rules |
required if using Prettier |
betterTailwind |
(opts) => Linter.Config[] |
eslint-plugin-better-tailwindcss factory; takes entryPoint (path to Tailwind entry CSS) and optional ignore (class names to skip) |
optional |
ignores |
Iterable<Linter.Config> & ((opts?) => Linter.Config[]) |
includeIgnoreFile helper plus GAIA defaults. Spread directly for defaults (...lint.ignores) or call with options to override (...lint.ignores({extra: ['.gaia/**']})). |
recommended |
Flat config is last-write-wins. Append override blocks after the gaia-lint spreads to disable, change, or scope rules.
export default defineConfig([
...lint.base,
...lint.react,
{rules: {'sonarjs/cognitive-complexity': 'off'}},
]);export default defineConfig([
...lint.base,
...lint.react,
{
files: ['app/legacy/**'],
rules: {'sonarjs/cognitive-complexity': 'off'},
},
]);export default defineConfig([
...lint.base,
{
languageOptions: {
parserOptions: {project: './tsconfig.eslint.json'},
},
},
]);import myPlugin from 'eslint-plugin-my-plugin';
export default defineConfig([
...lint.base,
...lint.react,
{
plugins: {'my-plugin': myPlugin},
rules: {'my-plugin/some-rule': 'error'},
},
]);eslint, prettier, and typescript are declared as peer
dependencies so consumers control their own versions and a single resolved
copy of each is installed in node_modules. Every other plugin
(eslint-plugin-react, typescript-eslint, eslint-plugin-import-x, etc.)
ships as a direct dependency of @gaia-react/lint so consumers don't
have to install or upgrade them individually.
Supported versions:
eslint ^9.0.0prettier ^3.0.0typescript ^5.0.0 || ^6.0.0
Four rules are implemented inside this package and ship as part of
guardrails.
Forbids TypeScript enum declarations. Enums emit runtime code, are not
tree-shakeable, conflate value and type space, and have well-known footguns
around numeric vs string enums and reverse mappings. Use a const object
plus a derived union type instead.
Opt out for a file or block:
{files: ['src/legacy/**'], rules: {'no-enum/no-enum': 'off'}}Forbids switch statements. They are an early-return / lookup-map / if
chain in disguise and are easy to misuse (fallthrough, missing default,
shadowed locals across cases). Replace with a lookup object, an early
return chain, or a discriminated-union exhaustiveness check.
Opt out for a file or block:
{files: ['src/parser/**'], rules: {'no-switch/no-switch': 'off'}}Forbids IIFEs ({(() => { ... })()}) inside JSX expression containers in .tsx and .jsx files. IIFEs obscure intent and allocate a new function on every render. Compute the value in a variable before the return statement, or use an inline && expression instead.
Opt out for a file or block:
{files: ['src/legacy/**'], rules: {'no-jsx-iife/no-jsx-iife': 'off'}}Standardizes the empty render on undefined instead of null in .tsx and
.jsx files. A return null inside a function that provably renders JSX (it
returns a JSX element elsewhere in the same scope) is rewritten to
return undefined. null and undefined are identical to React's reconciler
(both, with false/true, are the same empty slot); this is a consistency
convention that picks one of two equivalent forms, not a correctness or
performance fix.
Autofixable: --fix rewrites return null → return undefined. The fix is
deliberately conservative and only touches a return null whose enclosing
function is provably a render function, so a return null in a loader, action,
or plain utility is never altered. : null ternary arms are out of scope (the
report-only no-restricted-syntax selectors cover those).
Opt out for a file or block:
{files: ['src/legacy/**'], rules: {'no-null-render/no-null-render': 'off'}}reactRouter is opt-in because it is subtractive. storybook and
playwright only add rules, so spreading them can never weaken anything.
reactRouter turns no-empty-pattern off, and its glob
(**/routes/**/*.tsx) is not unique to React Router, since TanStack Router
uses a routes/ directory too. Shipping it inside react silently relaxed
the rule for every file-based router that isn't React Router.
...lint.react,
...lint.reactRouter, // React Router framework mode onlyOn React Router? Spread it. A route module destructures nothing from its
typed props (({}: Route.ComponentProps)), which no-empty-pattern reads as
an empty pattern.
On TanStack Router, or any other router? Omit it, and keep the rule.
createFileRoute(...)({component: Named}) never produces the empty-pattern
shape. You will most likely also want to ignore your generated route tree:
...lint.ignores({extra: ['**/routeTree.gen.ts']}),The /.react-router/** glob stays in the ignores defaults. It names a
directory that only exists in a React Router project, so it costs other
projects nothing.
betterTailwind is a factory because the underlying plugin needs to know
where your Tailwind entry CSS file lives.
...lint.betterTailwind({
entryPoint: './app/styles/tailwind.css',
ignore: ['plain-link', 'plain-table'],
}),| Option | Type | Required | Description |
|---|---|---|---|
entryPoint |
string |
yes | Path to your Tailwind entry CSS, used by the plugin to resolve the active class set. |
ignore |
string[] |
no | Class names the plugin should ignore in no-unregistered-classes (e.g. design-system tokens, plain-* utility shims). |
ignores produces a leading flat-config block that merges your
.gitignore plus GAIA defaults. .gitignore is picked up automatically
if it exists at the project root — no need to declare it.
lint.ignores is dual-shape: spread it directly for the default case
(no call), or call it with options to override.
...lint.ignores, // default: auto .gitignore + GAIA defaults
...lint.ignores({extra: ['coverage/**']}), // + extra globs
...lint.ignores({gitignore: 'config/.gitignore'}), // override the path
...lint.ignores({gitignore: false}), // opt out of the merge| Option | Type | Required | Description |
|---|---|---|---|
gitignore |
string | false |
no | Path to a .gitignore file (relative to cwd or absolute). Defaults to '.gitignore'. Silently skipped if the resolved path does not exist. Pass false to opt out entirely. |
extra |
string[] |
no | Extra ignore globs merged with GAIA defaults. |
The check-file rules in styleHygiene encode GAIA's folder layout (with
sourceDir substituted for the literal app/ prefix):
<sourceDir>/components/**— component file naming<sourceDir>/pages/**— route/page file naming<sourceDir>/hooks/**— hook file naming (use-*.ts)test/**— test harness naming
For most non-GAIA layouts, passing sourceDir to the factory is enough:
const lint = gaiaLint({sourceDir: 'src'});If the convention itself differs (e.g. PascalCase component files instead
of index.tsx), override the relevant check-file/* rules after the
styleHygiene spread:
const lint = gaiaLint({sourceDir: 'src'});
export default defineConfig([
...lint.base,
...lint.styleHygiene,
{
files: ['src/components/**'],
rules: {
'check-file/filename-naming-convention': [
'error',
{'src/components/**/*.{ts,tsx}': 'PASCAL_CASE'},
],
},
},
]);Or disable the GAIA check-file block entirely and reapply your own:
{rules: {'check-file/folder-naming-convention': 'off'}}This package follows SemVer.
- Patch. Plugin version bumps that do not change rule defaults, internal refactors, README fixes.
- Minor. New rules added, new options exposed on factories, new named exports, opt-in changes that do not break existing consumer configs.
- Major. Engine swaps (ESLint → Biome),
eslintmajor version bumps, removal/rename of named exports, default-rule changes that introduce new errors in previously-clean code.
MIT