Your .eslintrc is dead. ESLint v10.4.1 shipped, eslintrc went with it — no ESLINT_USE_FLAT_CONFIG flag, no LegacyESLint class, no fallback. And v9 hits end-of-life on August 6, so “we’ll deal with it later” became this week.
The official eslint flat config migration guide is exhaustive and useless when you have a React app, a Node API, and a monorepo all due Monday. What you actually want is three configs you can copy-paste — and a warning about the one pattern that passes on your laptop and silently fails in CI.
The 40-Second Mental Model (Skip If You’ve Read It)
The eslint flat config vs eslintrc translation table is short. extends becomes array spread. env becomes import globals from 'globals' plus a spread into languageOptions.globals. plugins: ['react'] becomes plugins: { react: pluginReact } — direct import, no string. overrides becomes more entries in the same array, each scoped with a files glob.
Every config object can scope itself with files and ignores. Order matters: later entries override earlier ones. Global ignores need their own object with only an ignores key — no files, no rules, nothing else. Get that wrong and the entry quietly stops being global.
If you understand “config is an array, each entry is a scope,” you understand 95% of the migration. The other 5% is where the CI breaks. We’ll get there.
Config 1: ESLint Flat Config for React + Vite
The easy stack, because the ecosystem ships flat presets. If you’re looking for an eslint flat config react vite setup, this is the one to copy.
Before (.eslintrc.json):
{
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"plugin:@typescript-eslint/recommended"
],
"env": { "browser": true, "es2022": true },
"parser": "@typescript-eslint/parser",
"settings": { "react": { "version": "detect" } }
}
After (eslint.config.js):
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import pluginReact from 'eslint-plugin-react';
import globals from 'globals';
export default [
js.configs.recommended,
...tseslint.configs.recommended,
pluginReact.configs.flat.recommended,
{
languageOptions: {
globals: { ...globals.browser },
parserOptions: { ecmaFeatures: { jsx: true } },
},
settings: { react: { version: 'detect' } },
},
{ ignores: ['dist/**', '.vite/**'] },
];
Three things to notice. The plugin import name (eslint-plugin-react) stays the same — what changes is how you reference it: a direct import, not a string. The TypeScript parser is now bundled into the typescript-eslint helper, so you don’t import the parser separately. (If you’re also wiring up tsconfig, strict mode, and path aliases, the practical TypeScript setup guide covers the full picture.) And settings lives in its own config object, not at the top level of every entry.
React is the easy one because somebody else did the work. (Still choosing a bundler? The build tool comparison makes that call easier.) Node, where you wire it yourself, is where the explicit nature of flat config starts to bite. This eslint 9 migration guide continues with the server-side setup.
Config 2: Node.js API (Express or Fastify)
Before (.eslintrc.json):
{
"extends": ["eslint:recommended"],
"env": { "node": true, "es2022": true },
"parserOptions": { "sourceType": "module" }
}
After (eslint.config.js):
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import globals from 'globals';
export default [
js.configs.recommended,
...tseslint.configs.recommended,
{
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: { ...globals.node },
parserOptions: { project: './tsconfig.json' },
},
},
{
files: ['**/*.test.ts'],
languageOptions: { globals: { ...globals.jest } },
},
{ ignores: ['dist/**', 'coverage/**'] },
];
parserOptions.project goes inside languageOptions, not at the top level — a small thing that breaks loudly with a confusing error message if you put it in the wrong place.
The test-file entry is what overrides used to be. It’s a sibling config object with a files glob and just the bits that differ — in this case, swapping globals.node for globals.jest. ESLint applies it on top of the earlier entries because order matters.
Notice the ignores object is bare: only an ignores key, no files, no rules. That’s what makes it global instead of scoped. Two stacks down. A monorepo is just a Node project times five — until per-package overrides arrive, and the pattern either clicks or collapses. (If you’re still picking your orchestration tool, the monorepo tool breakdown is the companion read.)
Config 3: Monorepo with Per-Package Overrides
The monorepo case is where people decide whether to migrate eslint to flat config or burn it all down. Here’s the before/after.
Before (.eslintrc.json at the repo root):
{
"extends": ["eslint:recommended"],
"overrides": [
{ "files": ["packages/api/**"], "env": { "node": true } },
{ "files": ["packages/web/**"], "env": { "browser": true } },
{ "files": ["packages/shared/**"], "env": { "es2022": true } }
]
}
After (eslint.config.js at the repo root):
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import pluginReact from 'eslint-plugin-react';
import globals from 'globals';
export default [
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['packages/api/**/*.{ts,tsx}'],
languageOptions: { globals: { ...globals.node } },
},
{
files: ['packages/web/**/*.{ts,tsx}'],
plugins: { react: pluginReact },
languageOptions: { globals: { ...globals.browser } },
settings: { react: { version: 'detect' } },
},
{
files: ['packages/**/*.test.ts'],
languageOptions: { globals: { ...globals.vitest } },
},
{ ignores: ['**/dist/**', '**/.turbo/**', 'coverage/**'] },
];
The order is the contract: base rules first, then per-package scopes, then per-test-file scope, then global ignores last. Each per-package entry only loads the plugins it needs — the web package gets React, the API doesn’t pay for it.
The trap people fall into: putting ignores inside a scoped entry and expecting it to act globally. It won’t. Scoped ignores only excludes files within that entry’s files scope. If you want to ignore dist/ everywhere, the entry has to be bare.
The configs work. You commit, push, and the CI lint job fails on a rule violation you can’t reproduce. Welcome to the part of the eslint flat config migration no one warns you about.
The Config That Breaks CI (And Not Your Laptop)
The pattern is glob resolution, and it has three variants.
Variant one: leading ./ or backslashes. files: ['./src/**/*.ts'] or files: ['src\\**\\*.ts'] works on macOS and matches nothing on Linux. Flat config resolves globs relative to the config file using forward slashes only. A leading ./ or a backslash silently produces zero matches. The config entry applies to no files. Lint passes vacuously.
Variant two: case sensitivity. files: ['Src/**/*.ts'] works on case-insensitive macOS and Windows filesystems. Linux CI is case-sensitive. Same vacuous pass. The rule never runs because the entry never matches anything.
Variant three: stale globals. Your local machine has globals v15. CI installs v16 because your lockfile drifted. The globals package added and removed keys between majors — globals.node lost __dirname in one version, globals.browser got new keys in another. The result is “X is not defined” errors that don’t appear locally.
The failure mode all three share is silence. Nothing throws. ESLint reports “0 problems.” Your bad config and a clean config look identical from the outside.
How to catch it: npx @eslint/config-inspector opens a localhost UI showing every file and every rule that applies, with the config entry that put it there. If any entry shows “matches 0 files,” you found the bug. The trap, named.
Verify Before You Push: Two Commands That Save the Afternoon
Once you complete your eslint.config.js tutorial setup, don’t trust a local pass — verify before you push.
npx @eslint/config-inspector — the inspector. Web UI, instant feedback on which files each entry covers. The fastest way to spot vacuous matches before they hide in CI.
npx eslint --print-config src/some/file.ts — dumps the fully-merged config for one file as JSON. Use it when you’re sure a rule should be applying and it isn’t. The output tells you which entries contributed and which lost.
For the CI-specific failures, run lint in a Linux container before you push:
docker run --rm -v $PWD:/app -w /app node:20 npx eslint .
Same Node major as CI, same filesystem case sensitivity, same glob behavior. If it passes there, it’ll pass in CI. While you’re at it, add eslint . to a pre-push hook, not pre-commit — pre-commit only lints staged files and misses exactly the glob-resolution bugs we just covered.
Tools in hand, configs in hand. Now what do you actually do Monday morning?
The Bottom Line
Start your eslint flat config migration with the smallest project first, then the one with the most CI runs (you’ll catch glob bugs faster), then the monorepo last. Copy the config that matches your stack. Replace the plugin and rule names with yours. Run the config inspector. Run ESLint in a Linux container. Then push.
Skip anything that promises flat config is simpler. It’s more explicit, which means more lines, which means more places to be wrong — and that’s the point. The configs above are explicit on purpose.
If you only remember one thing: a config entry that matches zero files passes silently. The inspector tells you. Use it. If your next move is deciding whether to keep ESLint at all, the Biome comparison is the honest version of that question.