ESLint 10 Killed .eslintrc — Here's the Flat Config You Need Now
From .eslintrc to eslint.config.mjs: An Upgrade That Made Me Re-understand ESLint
You upgrade ESLint, eagerly rerun lint, and instead of the familiar warning list, the screen shows an unfamiliar error:
ESLint couldn't find an eslint.config.(js|mjs|cjs) file.
Your first reaction is: My .eslintrc.json was written perfectly fine, why can't it be found suddenly?
It's not that you wrote it wrong; ESLint 10 has entirely removed .eslintrc.
Starting from ESLint 9, the old .eslintrc was marked as deprecated; by ESLint 10 (released in October 2025), it was completely removed—not just "not recommended," but "not even read." Those .eslintrc.json, .eslintrc.js, extends, env files in your project are nothing but air to the new version.
This article is here to solve this problem: Why ESLint deleted a configuration format that had been used for a decade, and how exactly to write the new flat config.
Understanding Flat Config in One Sentence
Flat config means: taking the original "layered cascade, extends inheriting everywhere" configuration and flattening it into a JS array. Each object in the array is a complete, independent configuration block.
The old .eslintrc looked like this (one file, a bunch of top-level fields):
{
"extends": ["eslint:recommended"],
"env": { "node": true },
"parserOptions": { "ecmaVersion": 2022, "sourceType": "module" },
"plugins": ["react"],
"rules": { "quotes": ["error", "double"] },
"overrides": [{ "files": ["*.test.js"], "env": { "jest": true } }]
}
The new flat config looks like this (a .mjs file, exporting an array):
import js from "@eslint/js";
import globals from "globals";
export default [
{
files: ["**/*.{js,mjs,cjs}"],
plugins: { js },
extends: ["js/recommended"],
languageOptions: { globals: globals.node },
rules: {
"no-var": 2,
"no-console": 1,
"quotes": ["error", "double"],
"semi": ["error", "always"],
"indent": ["error", 2],
},
},
];
The visible difference: From "a tree" to "a row of cards." No more extends inheritance chain, no more env, no more overrides.
Why Would ESLint Shoot Itself in the Foot? — This Is the Key
Many people's first reaction to flat config is "Isn't this just replacing JSON with JS? Same stuff, different package." No. Switching to JS is just the surface; behind it is a complete rewrite of how ESLint resolves configuration.
The old .eslintrc resolution method was called cascade. When ESLint needed to check src/a.js, it would:
src/a.js
│
├─ Read src/.eslintrc.json
├─ Go up and read .eslintrc.json
├─ extends: "eslint:recommended" → Go to node_modules to resolve this shared config
├─ extends: ["plugin:react/recommended"] → Resolve the react plugin's config again
└─ Merge all the above results into a "final config" before checking the file
See the problem? For every file, it had to retrace this entire "go up + resolve extends" chain. The larger your project and the deeper your shared configs, the slower and more unpredictable this resolution process became.
Even worse was implicit merging: which files actually made up the final effective config, in what order they were merged, and who overrode whom—you could basically only figure it out by "trial and error." Configuration bugs became the most frustrating ESLint problem—not that a rule was written wrong, but that "this rule actually isn't taking effect because another extends quietly overrode it."
Flat config's answer is to pull the root out: The configuration is just a piece of JS; you export an array, ESLint reads it in array order, and it's clear at a glance who comes first and who comes later. No cascade, no implicit merging, no extends resolution—extends: "eslint:recommended" becomes an explicit import + plugins + extends.
Summing up this "why" in one sentence:
The old
.eslintrcwas "runtime recursive config assembly"; flat config is "what you write is what you get, order is priority." The former saved a few lines of config at the cost of slow resolution and unpredictable results; the latter requires a few more imports in exchange for determinism and debuggability.
This is the confidence behind the ESLint team's decision to delete .eslintrc—it's not old wine in a new bottle; it's a complete rewrite of the "config resolution" mess.
Line-by-Line Breakdown: What These 10 Lines of Code Actually Do
The following is a complete, runnable eslint.config.mjs (used in this demo):
// This file is ESLint's flat config: exports an array of configuration objects
import js from "@eslint/js"; // ① Official package for built-in recommended rules
import globals from "globals"; // ② Global variables package (replaces old env)
export default [
{
files: ["**/*.{js,mjs,cjs}"], // ③ Which files this config applies to
plugins: { js }, // ④ Mount @eslint/js as a plugin named js
extends: ["js/recommended"], // ⑤ Reference the recommended config within this plugin
languageOptions: {
globals: globals.node, // ⑥ Declare Node global variables
},
rules: {
"no-var": 2, // ⑦ Rule list (2=error 1=warn 0=off)
"no-console": 1,
"quotes": ["error", "double"],
"semi": ["error", "always"],
"indent": ["error", 2],
},
},
];
Explaining a few points most likely to trip you up, one by one:
① import js from "@eslint/js" — The old extends: "eslint:recommended" was built into ESLint and didn't need installation. The new version splits it into the independent @eslint/js package, requiring npm i -D @eslint/js. The js object exported by this package carries the recommended config.
② import globals from "globals" — The old way of writing "env": { "node": true } declared Node global variables like process, console, __dirname. The new way requires installing the globals package and then using globals.node. This is the most easily overlooked step during migration—miss it, and process will be reported as an undefined variable with no-undef.
④ + ⑤ Why is extends still kept? Note that extends: ["js/recommended"] here is not the old-style "string to find in node_modules." It means "in the js plugin I mounted in step ④, find the config named recommended." The essence has changed from "inheritance" to "referencing a local variable."
⑦ Numbers vs Strings — The 2 in "no-var": 2 is a historical shorthand: 0 = off, 1 = warn, 2 = error. Both can be used in flat config; writing "error" is more readable, but the demo uses numbers to illustrate this mapping relationship.
Old-to-New Mapping Table: Follow This to Migrate
Moving each field from the old .eslintrc to flat config, refer to this table:
.eslintrc (Old) |
flat config (New) | Description |
|---|---|---|
extends: "eslint:recommended" |
import js from "@eslint/js" + plugins: { js } + extends: ["js/recommended"] |
Built-in recommended rules split into an independent package |
env: { node: true } |
import globals from "globals" + languageOptions: { globals: globals.node } |
Global variables changed to an explicit package |
parserOptions: { ecmaVersion, sourceType } |
languageOptions: { ecmaVersion, sourceType } |
Field moved into languageOptions |
plugins: ["react"] |
plugins: { react } |
Changed from a string array to an import object |
rules: { ... } |
rules: { ... } |
Completely unchanged |
overrides: [{ files, rules }] |
Each object in the array has its own files field |
The overrides concept disappears; each config block independently declares its target files |
Note the deeper meaning of the last row: the old version used overrides to "override" default configs; the new version has no concept of "default + override"—each config declares its own files scope, and the array order is the override order. Later config blocks have higher rule priority.
Experiment: What This Demo Actually Caught
Just talking concepts isn't interesting; let's run it once and show you the real output. This demo has an intentionally messy index.mjs:
let name = "wuxianhong";
let a = 1;
function hello() {
console.log(name + "hello");
}
hello();
Executing npx eslint ., the real output:
C:\...\eslint-demo\index.mjs
2:5 error 'a' is assigned a value but never used no-unused-vars
4:3 warning Unexpected console statement no-console
✖ 2 problems (1 error, 1 warning)
Three points worth remembering are hidden here:
① What really catches bugs for you is the no-unused-vars that comes with js/recommended, not the few rules you wrote by hand. let a = 1; is declared but never used, and is marked as an error. This is precisely the value of extends: ["js/recommended"]—it bundles a set of "potential bug detection" rules that are in effect even if you didn't write a single one.
② no-console reports a warning not an error, because the demo wrote "no-console": 1 (1 = warn). So although lint reports a problem, the exit code is 1 because of the error; the warning itself won't cause CI to fail.
③ Those quotes / semi / indent rules "didn't react" — not because they failed, but because this code happened to already use double quotes, semicolons, and 2-space indentation. Rules only show themselves when you violate them, which perfectly illustrates that "having a rule configured ≠ the rule is working"; you need to test it with code that actually violates it.
One More Jab: --fix Is Not a Panacea
Many people think eslint . --fix can fix all problems with one click. Let's run it:
$ npx eslint . --fix
C:\...\eslint-demo\index.mjs
2:5 error 'a' is assigned a value but never used no-unused-vars
4:3 warning Unexpected console statement no-console
✖ 2 problems (1 error, 1 warning)
The result is exactly the same, nothing was fixed. Because no-unused-vars and no-console are non-auto-fixable rules—ESLint can't decide for you "should this unused variable be deleted or did you intend to use it," nor can it decide for you "is this console.log a debugging leftover or a genuine log."
Only formatting rules (quotes, semi, indent, comma-dangle) can be auto-fixed by --fix. Logical and semantic rules can only report, not fix.
This understanding is important: If you want lint to auto-fix formatting in CI and only leave "must be manually reviewed" issues for developers, you need to distinguish these two types of rules—let --fix handle the formatting ones, and let the semantic ones report.
Two Pitfalls I Stepped Into in Real Projects
Pitfall One: type: commonjs clashes with .mjs
Look at this demo's package.json:
{
"type": "commonjs",
"main": "index.js",
"scripts": { "lint": "eslint ." }
}
"type": "commonjs" means .js files are parsed as CommonJS, but the config file is .mjs and the entry is index.mjs. Here, the .mjs extension forces ESM, so it can run—but the main field points to index.js, while the actual file is index.mjs, so node . will directly result in Cannot find module.
When migrating to flat config, the most hassle-free approach is: uniformly use eslint.config.mjs for the config file (.mjs is always parsed as ESM, unaffected by package.json's type), and don't let the type field and file extensions fight each other.
Pitfall Two: Forgetting to install globals, getting a flood of no-undef
The easiest step to miss during migration. The old env: { node: true } was "free"; the new version requires npm i -D globals and then languageOptions: { globals: globals.node }. Miss it, and all process, console, __dirname in your project will report 'process' is not defined. You'll think the rules are misconfigured, but it's actually that global variables weren't declared.
Summary: Remember These Three Things
First (Migration Mnemonic): extends replaced by @eslint/js, env replaced by globals, overrides replaced by files in the array, rules stay exactly the same.
Second (Understanding the Essence):
Flat config is not "replacing JSON with JS"; it's changing "runtime recursive config assembly" to "the array order you write is the priority."
Third (A Behavioral Change): Next time you see an .eslintrc tutorial, first check the ESLint version—9 is transitional, 10 has already deleted it. Following an old tutorial will only get you a "couldn't find an eslint.config file" message.
An Open Question: Is your project still using .eslintrc? When migrating to flat config, was it the globals replacement for env that tripped you up, or was it a specific custom plugin (like the new syntax for eslint-plugin-react) that blocked you? Let's chat in the comments, and I'll help you match it up.