ESLint 03 🛡️ The Flat Config — eslint.config.js as the v9+ default
ESLint’s configuration system changed fundamentally in v9. The legacy .eslintrc.* format, which relied on string-based plugin references, nested extends chains, and implicit environment detection, was replaced by a flat configuration file: eslint.config.js. The new format is a JavaScript module that exports an array of configuration objects. Plugins are imported as objects, not referenced by string names. File-specific overrides are expressed as separate objects in the array, matched by files globs, rather than nested inside overrides blocks. The mental model shifted from “configuration as a declarative document” to “configuration as executable JavaScript.”
This chapter covers the structure of the flat config file, the four key sections that appear in almost every configuration (files, ignores, languageOptions, rules), how plugins and parsers are loaded as imported modules, and how the defineConfig() helper introduced after v9’s initial release simplifies type safety and configuration extension. By the end, you will understand why the flat config looks the way it does, and how to read and write a configuration that ESLint v9 and v10 will accept.
Key point: The flat config is an array of plain JavaScript objects. Each object applies to a set of files, and the array order determines precedence. There is no extends string, no env shorthand, and no implicit plugin resolution.
Why the flat config replaced eslintrc
The string-based resolution problem. In the eslintrc system, plugins were loaded by string name: plugins: ["react"] told ESLint to find eslint-plugin-react in node_modules. Shareable configs were extended by string: extends: ["eslint:recommended", "plugin:react/recommended"]. This worked, but it made configuration opaque. You could not see what a plugin actually exported or how a shareable config was constructed. The flat config replaces strings with imports: import react from "eslint-plugin-react" and react.configs.recommended. The configuration becomes explicit JavaScript, and the plugin’s exports are visible in the file .
The environment shorthand problem. The eslintrc format had an env property: env: { browser: true, node: true }. This told ESLint to enable a predefined set of globals. The flat config removes env entirely. Globals are now imported from the globals package and spread into languageOptions.globals. This is more verbose, but it eliminates the implicit environment definitions and makes the set of globals explicit in the configuration file .
The override nesting problem. eslintrc files used an overrides array to apply different rules to different file patterns. Each override entry contained its own files pattern and its own nested configuration. In the flat config, overrides become separate objects in the exported array, each with its own files property. The array order determines precedence: later objects override earlier ones for matching files. This flattens the structure and makes the precedence order visible from top to bottom .
The type safety problem. The initial flat config release in v9 was an array export, which meant TypeScript users had no type checking on the configuration object. The defineConfig() helper, introduced in 2025, solves this by providing a typed wrapper that also automatically flattens nested arrays and objects. This addresses the TypeScript clunkiness feedback that ESLint received after v9’s release .
a. The structure of eslint.config.js
A flat config file exports an array. Each element in the array is a configuration object. The simplest valid configuration is an empty array, which means “use ESLint’s defaults for everything.” In practice, most configurations contain several objects: one for the recommended rules, one for language options, one for plugins, and one or more for file-specific overrides.
// eslint.config.js — the minimal structure
import js from "@eslint/js";
export default [
js.configs.recommended, // object 1: recommended rules
{
languageOptions: { // object 2: parser and globals
globals: { ...globals.node },
},
},
{
files: ["**/*.test.js"], // object 3: override for test files
rules: {
"no-unused-vars": "warn",
},
},
];
The array is processed in order. For a given file, ESLint applies every object whose files pattern matches (or whose files is absent, which means “match all files”), merging the configurations. Later objects override earlier ones for conflicting rules. The js.configs.recommended object has no files property, so it applies to every file. The third object only applies to files matching **/*.test.js, and its no-unused-vars setting overrides the recommended value for those files .
b. Files, ignores, and languageOptions
The files property is an array of glob patterns. If present, the configuration object only applies to files that match at least one pattern. If absent, the object applies to all files that ESLint processes. The patterns are matched against the file path relative to the location of the config file .
The ignores property, when used in an object with no other properties, defines global ignore patterns. This replaces the .eslintignore file from the eslintrc system. Flat config does not read .eslintignore files at all, so ignore patterns must be migrated into the configuration. A crucial difference: the pattern temp.js in flat config only matches a file named temp.js in the same directory as the config file. To ignore all files named temp.js, the pattern must be **/temp.js .
// eslint.config.js — global ignores
export default [
{
ignores: ["**/dist/**", "**/node_modules/**", "**/temp.js"],
},
// ... other config objects
];
The languageOptions property consolidates what the eslintrc format split across env, globals, parser, and parserOptions. The ecmaVersion and sourceType go under languageOptions. The parser is a direct object reference: languageOptions.parser is set to an imported parser module, not a string name. Globals are spread from the globals package .
// eslint.config.js — languageOptions
import globals from "globals";
import babelParser from "@babel/eslint-parser";
export default [
{
languageOptions: {
ecmaVersion: 2024,
sourceType: "module",
parser: babelParser,
globals: {
...globals.browser,
myCustomGlobal: "readonly",
},
},
},
];
c. Plugins and parsers as imports
The most visible change from eslintrc to flat config is how plugins are loaded. In eslintrc, plugins: ["react"] caused ESLint to resolve eslint-plugin-react by convention. In flat config, you import the plugin module and assign it to the plugins object under a local name .
// eslint.config.js — plugin as import
import react from "eslint-plugin-react";
import jsdoc from "eslint-plugin-jsdoc";
export default [
{
plugins: {
react: react, // local name "react"
jsdoc: jsdoc, // local name "jsdoc"
},
rules: {
"react/jsx-uses-react": "error",
"jsdoc/require-description": "warn",
},
},
];
The local name in the plugins object becomes the prefix for the plugin’s rules. If you assign react: reactPlugin, the rules are referenced as react/rule-name. The import makes the dependency explicit: if the plugin is not installed, the import fails at configuration load time, not at rule resolution time. This is arguably better because the error is immediate and points to the missing package .
Custom parsers follow the same pattern. Instead of parser: "@typescript-eslint/parser", you import the parser module and assign it to languageOptions.parser .
d. defineConfig and the reintroduction of extends
The initial flat config removed extends entirely. The theory was that the array structure was sufficient: to extend a configuration, you spread it into the array. In practice, this was confusing because different plugins exported their flat configs in different shapes. Some were objects, some were arrays, and some used properties like configs.flat.recommended while others used configs["flat/recommended"]. Extending a configuration from a plugin required knowing its specific export structure .
In March 2025, ESLint introduced defineConfig(), which brought back extends as a key inside configuration objects. The helper also provides type safety and automatically flattens nested arrays and objects, eliminating the need for manual spread operators in most cases .
// eslint.config.js — defineConfig with extends
import { defineConfig } from "eslint/config";
import js from "@eslint/js";
import tseslint from "typescript-eslint";
export default defineConfig([
{
files: ["**/*.ts"],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
],
rules: {
"@typescript-eslint/no-unused-vars": "error",
},
},
]);
The extends array accepts objects, arrays, and strings (for plugins that follow the recommended string naming convention). This provides a consistent extension mechanism regardless of how a plugin exports its configuration. The defineConfig() wrapper is optional; the array export still works. But for TypeScript projects and for configurations that extend multiple shareable configs, defineConfig() is the recommended approach .
Complete Example Session
// ============================================
// PART 1: MINIMAL FLAT CONFIG
// ============================================
// eslint.config.js
import js from "@eslint/js";
export default [
js.configs.recommended,
];
// Applies the recommended rules to all files.
// No files property means "match everything."
// ============================================
// PART 2: ADDING GLOBALS
// ============================================
import globals from "globals";
export default [
js.configs.recommended,
{
languageOptions: {
globals: {
...globals.node,
},
},
},
];
// The env property from eslintrc is gone.
// Globals are imported and spread explicitly.
// ============================================
// PART 3: FILE-SPECIFIC OVERRIDES
// ============================================
export default [
js.configs.recommended,
{
files: ["**/*.test.js"],
rules: {
"no-unused-vars": "warn",
},
},
];
// The override is a separate array object.
// It applies only to files matching the glob.
// ============================================
// PART 4: GLOBAL IGNORES
// ============================================
export default [
{
ignores: ["**/dist/**", "**/coverage/**"],
},
js.configs.recommended,
];
// The ignores object has no other properties.
// It must be the first object for clarity.
// Note: **/dist/** matches nested dist directories.
// ============================================
// PART 5: PLUGIN IMPORT
// ============================================
import react from "eslint-plugin-react";
export default [
{
plugins: {
react: react,
},
rules: {
"react/jsx-uses-react": "error",
},
},
];
// The plugin is an imported object, not a string.
// The local key "react" is the rule prefix.
// ============================================
// PART 6: CUSTOM PARSER
// ============================================
import babelParser from "@babel/eslint-parser";
export default [
{
languageOptions: {
parser: babelParser,
ecmaVersion: 2024,
sourceType: "module",
},
},
];
// Parser is assigned to languageOptions.parser.
// It is not a string reference.
// ============================================
// PART 7: defineConfig WITH TYPE SAFETY
// ============================================
import { defineConfig } from "eslint/config";
import js from "@eslint/js";
export default defineConfig([
js.configs.recommended,
{
files: ["src/**/*.js"],
rules: {
semi: "error",
},
},
]);
// defineConfig provides type checking in TypeScript.
// It also flattens nested arrays automatically.
// ============================================
// PART 8: USING extends (2025+)
// ============================================
import { defineConfig } from "eslint/config";
import js from "@eslint/js";
import tseslint from "typescript-eslint";
export default defineConfig([
{
files: ["**/*.ts"],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
],
},
]);
// extends accepts objects and arrays.
// This replaces manual spreading for most cases.
// ============================================
// PART 9: MIGRATING .eslintignore
// ============================================
// .eslintignore (legacy):
// dist/
// coverage/
// temp.js
// eslint.config.js:
export default [
{
ignores: ["**/dist/**", "**/coverage/**", "**/temp.js"],
},
// ... rules
];
// The pattern must be **/temp.js to match nested files.
// .eslintignore is not read by flat config.
// ============================================
// PART 10: COMPLETE CONFIGURATION
// ============================================
import js from "@eslint/js";
import globals from "globals";
import react from "eslint-plugin-react";
export default [
{
ignores: ["**/dist/**", "**/node_modules/**"],
},
js.configs.recommended,
{
languageOptions: {
globals: { ...globals.browser, ...globals.node },
},
},
{
files: ["**/*.jsx"],
plugins: { react: react },
rules: {
"react/jsx-uses-react": "error",
},
},
];
// Multiple objects, each with a specific purpose.
// Order matters: later objects override earlier ones.
The ten parts covered the minimal config, globals, file overrides, global ignores, plugin imports, custom parsers, defineConfig(), extends, .eslintignore migration, and a complete configuration.
Quick Reference
Flat config structure
| Property | Purpose |
|---|---|
files | Glob patterns this object applies to |
ignores | Global ignore patterns (object with no other keys) |
languageOptions | Parser, ecmaVersion, sourceType, globals |
plugins | Imported plugin objects, keyed by local name |
rules | Rule severity and options |
extends | Nested configurations (with defineConfig) |
eslintrc to flat config mapping
| eslintrc | Flat config |
|---|---|
env: { node: true } | languageOptions.globals: { ...globals.node } |
parser: "@babel/eslint-parser" | import parser from "@babel/eslint-parser" then languageOptions.parser: parser |
plugins: ["react"] | import react from "eslint-plugin-react" then plugins: { react } |
extends: ["eslint:recommended"] | import js from "@eslint/js" then js.configs.recommended |
overrides: [...] | Separate objects in the exported array |
.eslintignore | ignores object in the array |
Key differences from eslintrc
| Feature | eslintrc | Flat config |
|---|---|---|
| Format | JSON, YAML, or JS | JavaScript module only |
| Export | Object | Array of objects |
| Plugin loading | String convention | Imported module |
| Parser loading | String convention | Imported module |
| Environment | env shorthand | globals package spread |
| Ignore file | .eslintignore | ignores property |
Best Practices
✅ Do This:
// Export an array, not an object
export default [ js.configs.recommended ] // ✅
// Import plugins as modules
import react from "eslint-plugin-react" // ✅
// Use **/ prefix for ignore patterns that should match nested files
ignores: ["**/temp.js"] // ✅
// Put global ignores in a dedicated first object
{ ignores: ["**/dist/**"] } // ✅
// Use defineConfig for TypeScript and extends
import { defineConfig } from "eslint/config" // ✅
❌ Don’t Do This:
// Export an object (eslintrc style)
export default { rules: {} } // ❌
// Use string plugin names
plugins: ["react"] // ❌
// Use env shorthand
env: { node: true } // ❌
// Use .eslintignore (not read by flat config)
// .eslintignore with patterns // ❌
// Use ignores: ["temp.js"] expecting it to match nested files
ignores: ["temp.js"] // ❌ (only matches root)
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Plugin not found | Using string name instead of import | Import the plugin module and assign to plugins |
| Globals not recognized | env property used or globals not spread | Use languageOptions.globals with globals package |
| Ignore pattern does not work | Missing **/ prefix | Use **/pattern for nested matching |
| Parser not applied | String reference used | Import parser and assign to languageOptions.parser |
| Config not recognized | Exported object instead of array | Export an array |
| Rule not applied to specific files | files pattern missing or wrong | Add files to the config object |
Real-World Examples
1. Minimal recommended config
import js from "@eslint/js";
export default [js.configs.recommended];
2. Node globals
import globals from "globals";
export default [{ languageOptions: { globals: { ...globals.node } } }];
3. File-specific override
export default [{ files: ["**/*.test.js"], rules: { "no-unused-vars": "warn" } }];
4. Global ignores
export default [{ ignores: ["**/dist/**", "**/node_modules/**"] }];
5. React plugin
import react from "eslint-plugin-react";
export default [{ plugins: { react }, rules: { "react/jsx-uses-react": "error" } }];
6. Custom parser
import babelParser from "@babel/eslint-parser";
export default [{ languageOptions: { parser: babelParser } }];
7. defineConfig with extends
import { defineConfig } from "eslint/config";
export default defineConfig([{ extends: [js.configs.recommended] }]);
8. Multiple file patterns
export default [{ files: ["src/**/*.js", "test/**/*.js"], rules: {} }];
9. Migrated .eslintignore
export default [{ ignores: ["**/dist/**", "**/coverage/**", "**/temp.js"] }];
10. Complete configuration
import js from "@eslint/js";
import globals from "globals";
export default [
{ ignores: ["**/dist/**"] },
js.configs.recommended,
{ languageOptions: { globals: { ...globals.node } } },
];
Visual
The flat config array structure
┌─────────────────────────────────────────────────────────────┐
│ ESLINT.CONFIG.JS │
│ │
│ export default [ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ { ignores: ["**/dist/**"] } │ │
│ │ → global ignores, no other keys │ │
│ └─────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ js.configs.recommended │ │
│ │ → applies to all files │ │
│ └─────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ { languageOptions: { globals: {...} } } │ │
│ │ → applies to all files │ │
│ └─────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ { files: ["**/*.test.js"], rules: {...} } │ │
│ │ → applies only to test files │ │
│ └─────────────────────────────────────────────────────┘ │
│ ] │
│ │
│ Array order = precedence. Later overrides earlier. │
└─────────────────────────────────────────────────────────────┘
eslintrc vs flat config resolution
┌─────────────────────────────────────────────────────────────┐
│ ESLINTRC (LEGACY) │
│ │
│ .eslintrc.js │
│ ├── extends: ["eslint:recommended"] (string) │
│ ├── plugins: ["react"] (string) │
│ ├── env: { node: true } (shorthand) │
│ └── overrides: [ (nested) │
│ { files: ["*.test.js"], rules: {...} } │
│ ] │
│ │
│ ESLint resolves strings by convention. │
│ Nested overrides are hard to trace. │
│ │
│ ───────────────────────────────────────────────────────────│
│ │
│ FLAT CONFIG │
│ │
│ eslint.config.js │
│ import js from "@eslint/js" (explicit) │
│ import react from "eslint-plugin-react" │
│ │
│ export default [ │
│ js.configs.recommended, │
│ { plugins: { react } }, │
│ { languageOptions: { globals: { ...globals.node } } }, │
│ { files: ["**/*.test.js"], rules: {...} }, │
│ ] │
│ │
│ Everything is explicit. Order is visible. │
└─────────────────────────────────────────────────────────────┘
Plugin import vs string resolution
┌─────────────────────────────────────────────────────────────┐
│ PLUGIN LOADING │
│ │
│ ESLINTRC: │
│ plugins: ["react"] │
│ │ │
│ ▼ │
│ ESLint searches node_modules for eslint-plugin-react │
│ │ │
│ ▼ │
│ Loads by convention. │
│ If missing, error at rule resolution time. │
│ │
│ FLAT CONFIG: │
│ import react from "eslint-plugin-react" │
│ │ │
│ ▼ │
│ Node resolves the import at config load time. │
│ If missing, error immediately, pointing to the import. │
│ │ │
│ ▼ │
│ plugins: { react } │
│ │
│ The error is earlier and more precise. │
└─────────────────────────────────────────────────────────────┘
Global ignores pattern difference
┌─────────────────────────────────────────────────────────────┐
│ IGNORE PATTERNS │
│ │
│ .eslintignore: │
│ temp.js → ignores ALL files named temp.js │
│ │
│ Flat config: │
│ ignores: ["temp.js"] → ignores ONLY temp.js in the │
│ config file's directory │
│ │
│ ignores: ["**/temp.js"] → ignores ALL temp.js files │
│ │
│ The **/ prefix is required for recursive matching. │
└─────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| File name | eslint.config.js (or .mjs, .cjs) |
| Export type | Array of configuration objects |
| Default in v9 | Yes |
| Default in v8 | No (requires ESLINT_USE_FLAT_CONFIG=true) |
| Plugin loading | Imported module objects |
| Parser loading | Imported module objects |
| Globals | languageOptions.globals with globals package |
| Ignores | ignores property in a dedicated object |
| Type safety helper | defineConfig() from eslint/config |
extends support | Reintroduced in 2025 via defineConfig() |
Key takeaways:
- The flat config is an array, not an object. Each element is a configuration object. The array order determines precedence, with later objects overriding earlier ones for matching files.
- Plugins and parsers are imported, not referenced by string. The import makes dependencies explicit and surfaces missing packages at configuration load time.
envis gone. Globals come from theglobalspackage and are spread intolanguageOptions.globals.- File-specific overrides are separate array objects. The
filesproperty scopes an object to matching files, replacing the nestedoverridesarray from eslintrc. - Global ignores require a dedicated object. An object with only
ignoresand no other keys defines patterns that ESLint never processes. Patterns must use**/for recursive matching. defineConfig()andextendsare the modern extension mechanism. The helper provides type safety and reintroducesextendsas a consistent way to compose configurations from plugins and shareable configs.
Remember: The flat config is JavaScript, not a declarative document. That means you can write logic, import modules, and compose arrays. But the core pattern is simple: an array of objects, each scoped to a set of files, each containing the options and rules that apply to those files. The transition from eslintrc to flat config is a transition from convention-based resolution to explicit imports, and from nested overrides to a flat precedence order. Once you understand that the array is processed top to bottom, and that every plugin and parser is an import, the rest of the configuration is just rules and language options.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!