---
title: "ESLint Flat Config for Next.js 16: The Complete Setup Guide"
slug: "eslint-flat-config-for-next-js-16"
publishedAt: "2026-04-15T06:00:00.000Z"
dateModified: "2026-08-05T07:04:51.852Z"
canonical: "https://www.notesof.dev/eslint-flat-config-for-next-js-16"
author: "Hieu (Chris) Pham"
tags: ["ESLint", "Next.js"]
excerpt: "Upgraded to Next.js 16 and your linting just… stopped working? Yeah, same. Here's everything that broke for me and how I fixed it — so you don't have to waste a whole afternoon like I did."
---

# ESLint Flat Config for Next.js 16: The Complete Setup Guide

### What Broke (and Why)

Three things went wrong at the same time after upgrading to Next.js 16 (I'm using 16.2.3):

**`next lint` is gone.** Next.js 16 completely removed the built-in `next lint` command. If your `package.json` still has `"lint": "next lint"`, it will silently fail or throw an error. The `next build` command no longer runs linting automatically either.

**`FlatCompat` stopped working.** If you were using `@eslint/eslintrc`'s `FlatCompat` to bridge your old `eslint-config-next` into ESLint 9 flat config — that shim layer broke when `eslint-config-next` hit peer dependency conflicts with Next.js 16. `FlatCompat` was always meant as a migration tool, not a permanent solution.

**`ajv` version mismatch.** A loose pnpm override (`"ajv@<6.14.0": ">=6.14.0"`) allowed `ajv` to resolve to v8.x. ESLint 9 depends on `ajv` v6 internally, and v8 has a completely different API. The result? Cryptic schema compilation errors that never mention `ajv` by name.

### The Fix: Native Flat Config (No Shims)

The solution is to ditch `FlatCompat` and `eslint-config-next` entirely, and import each plugin directly.

### Step 1 — Install Dependencies

Remove the old packages first:

```bash
pnpm remove @eslint/eslintrc eslint-config-next
```

Then install the replacements:

```bash
pnpm add -D -E eslint@latest typescript-eslint eslint-plugin-react eslint-plugin-react-hooks @next/eslint-plugin-next
```

**Optional extras** (add what you need):

```bash
# Accessibility
pnpm add -D -E eslint-plugin-jsx-a11y

# Code style
pnpm add -D -E @stylistic/eslint-plugin

# Tailwind CSS (v4 beta)
pnpm add -D -E eslint-plugin-better-tailwindcss@beta

# MDX support
pnpm add -D -E eslint-plugin-mdx

# React Compiler (experimental)
pnpm add -D -E eslint-plugin-react-compiler
```

### Step 2 — Create `eslint.config.mjs`

Delete any old `.eslintrc.*` files and create `eslint.config.mjs` in your project root:

```js
import { defineConfig } from "eslint/config";
import { configs as tseslintConfigs } from "typescript-eslint";
import reactPlugin from "eslint-plugin-react";
import reactHooksPlugin from "eslint-plugin-react-hooks";
import nextPlugin from "@next/eslint-plugin-next";

export default defineConfig([
  // 1. Global ignores — must be its own config object
  {
    name: "project/ignores",
    ignores: [".next/", "node_modules/", "public/", ".vscode/"],
  },

  // 2. TypeScript rules
  {
    name: "project/typescript",
    files: ["**/*.{ts,tsx}"],
    extends: [tseslintConfigs.recommended],
    languageOptions: {
      parserOptions: {
        projectService: true,
        tsconfigRootDir: import.meta.dirname,
      },
    },
  },

  // 3. React + Next.js rules
  {
    name: "project/react-next",
    files: ["**/*.{js,jsx,ts,tsx,mjs}"],
    plugins: {
      react: reactPlugin,
      "react-hooks": reactHooksPlugin,
      "@next/next": nextPlugin,
    },
    rules: {
      ...reactPlugin.configs.recommended.rules,
      ...reactPlugin.configs["jsx-runtime"].rules,
      ...reactHooksPlugin.configs["recommended-latest"].rules,
      ...nextPlugin.configs.recommended.rules,
      ...nextPlugin.configs["core-web-vitals"].rules,
      "react/prop-types": "off", // Using TypeScript instead
    },
    settings: {
      react: { version: "detect" },
    },
  },
]);
```

That's the minimal working config. Each plugin's rules are spread explicitly — verbose, but you can see exactly what's active. No magic, no shim layer.

### Step 3 — Update `package.json` Scripts

```json
{
  "scripts": {
    "dev": "next dev",
    "build": "pnpm lint && next build",
    "start": "next start",
    "lint": "eslint --cache --cache-location .next/cache/eslint/",
    "lint:fix": "eslint --fix"
  }
}
```

Key changes: `next lint` → `eslint`, and the build script explicitly runs lint first since Next.js won't do it for you anymore.

### Step 4 — Fix the "ajv" Override (if applicable)

If you have an `ajv` override in `package.json` (common for security patches), pin it tightly:

```json
{
  "pnpm": {
    "overrides": {
      "ajv@<6.14.0": "~6.14.0"
    }
  }
}
```

The `~` keeps it on `6.14.x`. Using `>=` lets pnpm resolve to v8, which will break ESLint silently.

### Step 5 — Clean Up `next.config.mjs`

If your Next config had an `eslint` option, remove it — it's no longer supported:

```js
const nextConfig = {
  // Remove this — no longer supported in Next.js 16
  // eslint: { ignoreDuringBuilds: true },
};
```

### Step 6 — Run It

```bash
pnpm lint
```

If everything is wired up correctly, ESLint should run cleanly against your source files.

### Leveling Up: Accessibility, Styling, and More

The config above is the practical minimum. Here's how to add more capabilities:

### Accessibility with `jsx-a11y`

```js
import jsxA11yPlugin from "eslint-plugin-jsx-a11y";

// Inside your react-next config block:
plugins: {
  // ... existing plugins
  "jsx-a11y": jsxA11yPlugin,
},
rules: {
  // ... existing rules
  ...jsxA11yPlugin.configs.strict.rules,
  "jsx-a11y/alt-text": ["warn", { elements: ["img"], img: ["Image"] }],
},
```

### Code Style with `@stylistic/eslint-plugin`

```js
import stylisticPlugin from "@stylistic/eslint-plugin";

// Add as a separate config block:
{
  name: "project/stylistic",
  files: ["**/*.{js,mjs,ts,tsx}"],
  plugins: { "@stylistic": stylisticPlugin },
  rules: {
    ...stylisticPlugin.configs["disable-legacy"].rules,
    ...stylisticPlugin.configs.recommended.rules,
    "@stylistic/indent": ["warn", 2],
    "@stylistic/quotes": ["warn", "single", { avoidEscape: true }],
    "@stylistic/semi": ["warn", "never"],
  },
},
```

### Tailwind CSS Linting

```js
import tailwindcssPlugin from "eslint-plugin-better-tailwindcss";

{
  name: "project/tailwindcss",
  files: ["**/*.ts?(x)"],
  plugins: { "better-tailwindcss": tailwindcssPlugin },
  rules: {
    ...tailwindcssPlugin.configs.recommended.rules,
  },
  settings: {
    "better-tailwindcss": {
      entryPoint: "app/global.css",
    },
  },
},
```

### Debugging Tips

**ESLint errors are cryptic?** Check your `ajv` version first. Run `pnpm why ajv`. If v8 shows up alongside ESLint, that's your problem.

**Rules not applying?** Use the built-in Config Inspector to see exactly what's active:

```bash
pnpm eslint --inspect-config
```

This opens a browser UI at `localhost:7777` where you can explore every rule and which files it targets.

**Want to see the resolved config for a specific file?**

```bash
pnpm eslint --print-config src/app/page.tsx
```

### Key Takeaways

- **`FlatCompat` is a migration tool, not a destination.** Migrate to native flat config and you'll never deal with shim breakage again.
- **Loose version overrides are dangerous.** `>=` in overrides can pull in breaking major versions silently. Use `~` unless you explicitly want that.
- **Name your config blocks.** Adding `name: "project/whatever"` to each object makes debugging with Config Inspector so much easier.
- **Order matters.** Global ignores first, then general rules, then specific overrides.
