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

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.
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:
pnpm remove @eslint/eslintrc eslint-config-next
Then install the replacements:
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):
# 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:
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
{
"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:
{
"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:
const nextConfig = {
// Remove this — no longer supported in Next.js 16
// eslint: { ignoreDuringBuilds: true },
};
Step 6 — Run It
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
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
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
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:
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?
pnpm eslint --print-config src/app/page.tsx
Key Takeaways
FlatCompatis 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.