ESLint Config: .eslintrc.json (Legacy) and eslint.config.js (Flat Config) Explained
Last updated:
ESLint has two config systems. Flat config (eslint.config.js) is the default in ESLint 9, released in April 2024. The legacy .eslintrc.* system (.eslintrc.json, .eslintrc.js, .eslintrc.yml) still works in ESLint 9 but is officially deprecated and will be removed in a future major release. The two systems use different file names, different shapes, and different mental models — they are not interchangeable. If you're starting a new project in 2026, use flat config. If you're maintaining a project still on ESLint 8 with .eslintrc.json, you can stay on it for now, but plan the migration before bumping to ESLint 9.
Need to validate an .eslintrc.json file? Paste it into Jsonic's JSON Validator — it pinpoints syntax errors with line and column numbers before ESLint refuses to load it.
Flat config (ESLint 9+) — the new default
A flat config file lives at the project root and exports an array of config objects. Each object can target specific files via a files glob, register plugins as imported values, set languageOptions (parser, globals, ecmaVersion), and declare rules. ESLint merges objects in array order — later objects override earlier ones for any file they both match.
// eslint.config.js (or eslint.config.mjs if package.json has "type": "module")
import js from "@eslint/js"
import globals from "globals"
export default [
js.configs.recommended,
{
languageOptions: {
ecmaVersion: 2024,
sourceType: "module",
globals: { ...globals.browser, ...globals.node },
},
rules: {
"no-unused-vars": "warn",
"no-console": ["error", { allow: ["warn", "error"] }],
},
},
{
ignores: ["dist/**", "coverage/**", "*.config.js"],
},
]Three things to notice. First, the file is JavaScript — comments work, you can call functions, you can compute config dynamically. Second, presets are imported (import js from "@eslint/js") and spread into the array, not referenced by string. Third, the final { ignores: [...] } object replaces the old .eslintignore file entirely.
Legacy .eslintrc.json — what was there before
The eslintrc system was the only config format from ESLint 1 through 8. A single root.eslintrc.json (or .eslintrc.js, .eslintrc.yml) held one config object. Subdirectories could drop in their own .eslintrc files which cascaded — ESLint walked from the linted file up the directory tree, merging configs as it went. String-based extends resolved to packages on disk, plugins were referenced by string name, and a separate .eslintignore file listed paths to skip.
{
"root": true,
"env": {
"browser": true,
"node": true,
"es2022": true
},
"parserOptions": {
"ecmaVersion": 2022,
"sourceType": "module"
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"plugins": ["@typescript-eslint"],
"parser": "@typescript-eslint/parser",
"rules": {
"no-unused-vars": "warn",
"no-console": ["error", { "allow": ["warn", "error"] }]
},
"ignorePatterns": ["dist/", "coverage/"]
}Why it was replaced: the cascading resolution was slow and surprising (a stray .eslintrcin a parent directory could change a child's lint result), string-based extends hid where rules actually came from, plugins had to be registered both as a string name and resolved separately on disk, and env mapped to opaque global lists you could not inspect. Flat config fixes all five problems by replacing strings with values and the cascade with one root file.
Side-by-side comparison: flat vs legacy
| Concept | Legacy .eslintrc.json | Flat eslint.config.js |
|---|---|---|
| File name | .eslintrc.json / .eslintrc.js / .eslintrc.yml | eslint.config.js / .mjs / .cjs / .ts |
| Shape | One config object | Array of config objects |
| Extends presets | "extends": ["eslint:recommended"] | Import and spread: ...js.configs.recommended |
| Plugins | "plugins": ["@typescript-eslint"] (string) | plugins: { "@typescript-eslint": tseslint } (value) |
| Parser | "parser": "@typescript-eslint/parser" at top level | languageOptions: { parser: tseslintParser } |
| parserOptions | Top-level parserOptions | Inside languageOptions |
| Environment globals | "env": { "browser": true } | languageOptions.globals: { ...globals.browser } |
| Ignore paths | Separate .eslintignore file + ignorePatterns | { ignores: ["dist/**"] } as own config object |
| Per-file overrides | overrides array | Additional config objects with files glob |
| Cascading config | Yes — folders inherit parent .eslintrc | No — one root file controls everything |
| Comments in file | Only in .eslintrc.js / .eslintrc.yml, not .json | Always (it's JavaScript) |
Rule severity is one of the few things that did not change. Both systems use the same three levels:
| Severity | Numeric form | Effect |
|---|---|---|
"off" | 0 | Rule disabled; no output |
"warn" | 1 | Reported as warning; exit code stays 0 |
"error" | 2 | Reported as error; ESLint exits non-zero (fails CI) |
Migrating from .eslintrc.json to eslint.config.js
The fastest path is the official migration CLI: npx @eslint/migrate-config .eslintrc.json. It outputs an eslint.config.js that captures every rule from the legacy file, including extends, plugins, and overrides. You should still hand-edit it afterward to use the modern shape. Here is a concrete before/after for a 3-rule config:
// BEFORE: .eslintrc.json
{
"root": true,
"env": { "browser": true, "es2022": true },
"parserOptions": { "ecmaVersion": 2022, "sourceType": "module" },
"extends": ["eslint:recommended"],
"rules": {
"no-unused-vars": "warn",
"eqeqeq": "error",
"prefer-const": "error"
},
"ignorePatterns": ["dist/"]
}// AFTER: eslint.config.js
import js from "@eslint/js"
import globals from "globals"
export default [
js.configs.recommended,
{
languageOptions: {
ecmaVersion: 2022,
sourceType: "module",
globals: { ...globals.browser },
},
rules: {
"no-unused-vars": "warn",
"eqeqeq": "error",
"prefer-const": "error",
},
},
{ ignores: ["dist/**"] },
]Step-by-step: (1) Delete .eslintrc.json and .eslintignore if you have one. (2) Run npm i -D @eslint/js globals. (3) Create eslint.config.js with the AFTER shape. (4) Run npx eslint --inspect-config to verify the resolved config matches what you expected, then npx eslint . to confirm the same lint output.
Plugins: how to use them in flat config (no more @typescript-eslint/eslint-plugin string)
Legacy .eslintrc registered plugins twice — once as a string name in the plugins array, and again implicitly when ESLint resolved the package on disk. Flat config drops the string. You import the plugin module and pass it as a value, then refer to its rules under whatever namespace key you chose.
// Legacy: rule referenced by string-only
{
"plugins": ["react"],
"extends": ["plugin:react/recommended"],
"rules": { "react/jsx-uses-react": "error" }
}// Flat config: plugin is a value, namespace is yours to choose
import react from "eslint-plugin-react"
export default [
{
files: ["**/*.{jsx,tsx}"],
plugins: { react }, // "react" is the namespace
rules: {
...react.configs.recommended.rules, // bring in the preset's rules
"react/jsx-uses-react": "error",
},
settings: { react: { version: "detect" } },
},
]The namespace key is the prefix ESLint uses when reporting violations (react/jsx-uses-react). It does not have to match the plugin package name, though by convention it usually does. Many modern plugins now ship their own flat-config-ready preset: eslint-plugin-import-x, eslint-plugin-react (via react.configs.flat.recommended in recent versions), eslint-plugin-unicorn. Check each plugin's README for the flat-config import path.
TypeScript with flat config: typescript-eslint and tseslint.config()
The typescript-eslint package (note: single package, no scope) is the modern unified entry point. It exposes a tseslint.config() helper that wires parser, plugin, and rules together with full TypeScript types on the config itself.
// npm i -D eslint @eslint/js typescript-eslint globals
// eslint.config.js
import js from "@eslint/js"
import tseslint from "typescript-eslint"
import globals from "globals"
export default tseslint.config(
js.configs.recommended,
...tseslint.configs.recommended, // spread: it returns an array
{
languageOptions: {
ecmaVersion: 2024,
sourceType: "module",
globals: { ...globals.node },
},
rules: {
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
},
},
{ ignores: ["dist/**", "coverage/**"] },
)For type-aware rules (rules that need the TypeScript program, like @typescript-eslint/no-floating-promises), swap recommended for recommendedTypeChecked and pass parserOptions.project: true inside languageOptions. Type-aware linting is slower (it loads tsconfig.json and builds the program) but catches an entire class of bugs unreachable to syntax-only rules.
Common rules and recommended preset compositions
Most teams compose 3–5 presets rather than hand-picking rules. Here are the building blocks you will use in nearly every project:
| Package | What it provides | Flat-config import |
|---|---|---|
@eslint/js | ESLint's built-in core rules (formerly eslint:recommended) | import js from "@eslint/js" |
typescript-eslint | TS parser + TS-specific rules + tseslint.config() helper | import tseslint from "typescript-eslint" |
eslint-plugin-react | React-specific rules (jsx-key, no-unknown-property, hooks) | import react from "eslint-plugin-react" |
eslint-plugin-react-hooks | The two React Hooks rules (rules-of-hooks, exhaustive-deps) | import hooks from "eslint-plugin-react-hooks" |
eslint-config-prettier | Turns off rules that conflict with Prettier — put it LAST in the array | import prettier from "eslint-config-prettier" |
globals | Pre-built sets of global identifiers (browser, node, jest, etc.) | import globals from "globals" |
Order matters in the exported array. ESLint applies configs top-to-bottom, so put the most-permissive presets first and Prettier's rule-disabling config last so it wins over anything before it. A typical full stack ordering: js.configs.recommended → tseslint.configs.recommended → react flat preset → react-hooks rules → your custom rules → prettier → { ignores: [...] }.
Key terms
- ESLint
- An open-source static analysis tool for JavaScript and TypeScript. Reads source files, parses them into an AST, and reports rule violations. Created by Nicholas C. Zakas in 2013; ESLint 9 was released in April 2024.
- Flat config
- The config system introduced in ESLint 8.21 and made default in ESLint 9. Uses a single root
eslint.config.jsfile that exports an array of config objects. Replaces the cascading.eslintrcsystem. - .eslintrc (legacy)
- The original ESLint config system used through ESLint 8. Lives in files named
.eslintrc.json,.eslintrc.js,.eslintrc.yml, or aneslintConfigkey insidepackage.json. Deprecated as of ESLint 9; will be removed in a future major release. - Rule severity
- How ESLint reports a rule violation. Three levels:
off/0(disabled),warn/1(reported, exit 0),error/2(reported, exit non-zero — fails CI). Same in both config systems. - Plugin
- A third-party package that adds new rules, configs, parsers, or processors to ESLint. Examples:
eslint-plugin-react,typescript-eslint,eslint-plugin-import. In flat config, plugins are imported values; in legacy config, they were referenced by string name. - Parser
- The component that turns source text into an AST for ESLint to analyze. Default is
espree(a vanilla JS parser). TypeScript and Vue need their own parsers (@typescript-eslint/parser,vue-eslint-parser) wired in vialanguageOptions.parser.
Frequently asked questions
Should I use .eslintrc.json or eslint.config.js in 2026?
For any new project in 2026, use eslint.config.js (flat config). It is the default in ESLint 9 (released 2024) and is the only config system that will receive new features going forward. The legacy .eslintrc.* family (.eslintrc.json, .eslintrc.js, .eslintrc.yml) still works but is officially deprecated and will be removed in a future major release. If you are maintaining an existing project still on ESLint 8 with .eslintrc.json, you can stay on it for now, but plan the migration before bumping to ESLint 9. The migration is mostly mechanical: rename the file, convert extends strings to imported config objects, and move env/parserOptions under languageOptions.
What changed in ESLint 9 flat config?
ESLint 9 (April 2024) made flat config (eslint.config.js) the default. A flat config file exports an array of config objects instead of one object. The string-based extends field is gone — you import config objects directly and spread them into the array. env (browser, node, es6) is replaced by languageOptions.globals, using the globals npm package. parser and parserOptions move into languageOptions. Plugins are now imported as JavaScript values rather than referenced by string name. The eslintrc-style ignorePatterns field is replaced by an ignores key in the config objects. Cascading config (a .eslintrc per folder) is gone — one root file controls everything via the files glob on each config object.
How do I migrate from .eslintrc.json to flat config?
Three steps. (1) Rename .eslintrc.json to eslint.config.js (or .mjs if package.json has "type": "module"). (2) Replace extends arrays with imports — for example extends: ["eslint:recommended"] becomes import js from "@eslint/js" then ...js.configs.recommended in the exported array. (3) Move env to languageOptions.globals using the globals package, and move parser/parserOptions under languageOptions. The ESLint team provides an official @eslint/migrate-config CLI you can run: npx @eslint/migrate-config .eslintrc.json. It produces a working flat config you then refine. .eslintignore content moves into the ignores key of one config object.
Can .eslintrc.json have comments?
No. .eslintrc.json is strict JSON (RFC 8259) and does not allow // or /* */ comments — adding any comment causes ESLint to fail config loading. If you need comments inside ESLint config, use either .eslintrc.js (a JavaScript file that exports the config object, where any comment is fine) or .eslintrc.yml (YAML supports # comments). Or switch to flat config (eslint.config.js) which is JavaScript by definition and supports comments natively. This is the same constraint that applies to package.json, tsconfig.json strict mode, and any other .json file. See our JSON Comments guide for the general workaround set.
Where do I put env: { browser: true } in flat config?
env is gone in flat config. The replacement is languageOptions.globals, populated from the globals npm package. Example: import globals from "globals" then in your config object set languageOptions: { globals: { ...globals.browser, ...globals.node } }. The globals package ships pre-built sets for browser, node, es2024, jest, mocha, worker, and dozens of others. This is more explicit than legacy env — you see exactly which global identifiers ESLint considers defined, and you can compose them per file group using the files glob on different config objects.
How do I use TypeScript-eslint with flat config?
typescript-eslint ships a tseslint.config() helper that produces a flat-config-compatible array. Install: npm i -D typescript-eslint. Then in eslint.config.js: import tseslint from "typescript-eslint" and export default tseslint.config(tseslint.configs.recommended, { rules: { ... } }). The helper handles plugin registration, parser wiring (typescript-eslint becomes the parser via languageOptions.parser), and type-aware lint rules if you opt into tseslint.configs.recommendedTypeChecked. There is no more "@typescript-eslint/eslint-plugin" string in extends — you import the plugin and pass it as a value. tseslint.config() also provides better TypeScript types for the config itself if you use eslint.config.ts.
How do I ignore files in flat config?
There is no .eslintignore in flat config. Instead, add a config object with only an ignores key: { ignores: ["dist/**", "node_modules/**", "coverage/**", "*.config.js"] }. This must be its OWN config object — combining ignores with rules in the same object only ignores those files for that one config, not globally. The root-level ignores object is the closest equivalent to .eslintignore. ESLint flat config also has built-in defaults: node_modules and .git are ignored automatically. To debug what files ESLint actually lints, run npx eslint --inspect-config or npx eslint . --debug to see resolved config and matched files.
What's the difference between eslint.config.js and eslint.config.mjs?
Both are valid flat config filenames. ESLint 9 supports eslint.config.js, .mjs, .cjs, and (with experimental loader) .ts. The choice depends on your package.json type field. If "type": "module" is set, eslint.config.js is treated as an ES module and you use import/export syntax. If type is "commonjs" or unset, eslint.config.js is CommonJS — you must use require() and module.exports. eslint.config.mjs is always ES module regardless of package.json. eslint.config.cjs is always CommonJS. Pick .mjs for ESM and .cjs for CommonJS to remove ambiguity, or use .js and match it to your package.json type.
Further reading and primary sources
- ESLint Docs — Configure ESLint — Official reference for flat config shape, languageOptions, and rule severity
- ESLint 9.0.0 release post — What changed in ESLint 9, including flat config becoming default
- typescript-eslint — Getting Started (Flat Config) — Official typescript-eslint setup with tseslint.config() helper
- @eslint/js on npm — The package that exposes eslint:recommended as a flat config object
- eslint-config-prettier — Turns off ESLint rules that conflict with Prettier — flat config supported