| | |

ESLint 03 🛡️ The Flat Config — eslint.config.js as the v9+ default

⏱️ Estimated read: 14 min | |

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

PropertyPurpose
filesGlob patterns this object applies to
ignoresGlobal ignore patterns (object with no other keys)
languageOptionsParser, ecmaVersion, sourceType, globals
pluginsImported plugin objects, keyed by local name
rulesRule severity and options
extendsNested configurations (with defineConfig)

eslintrc to flat config mapping

eslintrcFlat 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
.eslintignoreignores object in the array

Key differences from eslintrc

FeatureeslintrcFlat config
FormatJSON, YAML, or JSJavaScript module only
ExportObjectArray of objects
Plugin loadingString conventionImported module
Parser loadingString conventionImported module
Environmentenv shorthandglobals package spread
Ignore file.eslintignoreignores 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

PitfallWhy It HappensFix
Plugin not foundUsing string name instead of importImport the plugin module and assign to plugins
Globals not recognizedenv property used or globals not spreadUse languageOptions.globals with globals package
Ignore pattern does not workMissing **/ prefixUse **/pattern for nested matching
Parser not appliedString reference usedImport parser and assign to languageOptions.parser
Config not recognizedExported object instead of arrayExport an array
Rule not applied to specific filesfiles pattern missing or wrongAdd 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

ItemValue
File nameeslint.config.js (or .mjs, .cjs)
Export typeArray of configuration objects
Default in v9Yes
Default in v8No (requires ESLINT_USE_FLAT_CONFIG=true)
Plugin loadingImported module objects
Parser loadingImported module objects
GlobalslanguageOptions.globals with globals package
Ignoresignores property in a dedicated object
Type safety helperdefineConfig() from eslint/config
extends supportReintroduced 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.
  • env is gone. Globals come from the globals package and are spread into languageOptions.globals.
  • File-specific overrides are separate array objects. The files property scopes an object to matching files, replacing the nested overrides array from eslintrc.
  • Global ignores require a dedicated object. An object with only ignores and no other keys defines patterns that ESLint never processes. Patterns must use **/ for recursive matching.
  • defineConfig() and extends are the modern extension mechanism. The helper provides type safety and reintroduces extends as 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.

Was this helpful?


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!