I recently jumped into a project that uses Turborepo, and the ESLint configuration was an absolute mess β duplicate config files scattered across packages, inconsistent rules, and no clear source of truth. After spending too much time hunting down where specific linting rules were coming from, I decided to modernise the whole setup using a centralised approach, and the difference in developer experience has been night and day for the team.
The Problem
Managing ESLint configurations across a large monorepo can become a maintenance nightmare. When you're working with multiple Next.js applications, library packages, and shared utilities, each with their own linting requirements, the configuration duplication quickly spirals out of control. In this post, I'll walk through how to modernize your ESLint setup in a Turborepo monorepo, reducing duplication while maintaining package-specific rules and enabling fast, granular linting with Husky and lint-staged.
A typical monorepo houses multiple applications and libraries:
- Next.js applications (admin dashboards, customer-facing apps, internal tools)
- Library packages (shared components, data parsers, utilities)
- Shared code (types, API clients, database clients)
Before a centralised approach, you're likely dealing with:
- Duplicate
.eslintrc.jsonfiles in every package - Separate
.eslintignorefiles scattered across packages - Duplicated ESLint and Prettier dependencies in multiple package.json files
- No centralised way to update rules globally
- Inconsistent configurations leading to subtle differences between packages
But the biggest pain point? Running lint-staged from the monorepo root. You want to lint only changed files on pre-commit, but each package's linting rules need to be respected. This requires a clever solution.
The Solution: Centralised Config Package + Root Orchestration
The architecture has three main parts:
- Centralised ESLint config package (
@repo/eslint-config) - Composable preset configurations (base, Next.js, library)
- Root orchestration config that maps file patterns to the appropriate presets
Let's dive into each piece.
Part 1: The Centralised Config Package
Create a new internal package at packages/eslint-config/ with three composable configurations:
Base Config (packages/eslint-config/base.js)
This is the foundation that all other configs extend. It includes TypeScript support, Prettier integration, and Turborepo-specific rules:
import js from '@eslint/js'
import eslintConfigPrettier from 'eslint-config-prettier'
import turboPlugin from 'eslint-plugin-turbo'
import tseslint from 'typescript-eslint'
/**
* A shared ESLint configuration for the repository.
*
* @type {import("eslint").Linter.Config[]}
* */
export const config = [
js.configs.recommended,
eslintConfigPrettier,
...tseslint.configs.recommended,
{
plugins: {
turbo: turboPlugin,
},
rules: {
// your rules here
},
},
{
plugins: {
// your plugins here
},
},
{
ignores: [
'**/node_modules/**',
'**/.next/**',
'**/dist/**',
'**/build/**',
'**/out/**',
'**/.turbo/**',
'**/.cache/**',
'**/coverage/**',
'**/*.d.ts',
'**/*.generated.ts',
'**/*.gen.ts',
'**/test-results/**',
'**/playwright-report/**',
],
},
]
Key features:
- ESLint 9 flat config format (
.jsinstead of.json) - TypeScript support via
typescript-eslintv8.26+ - Prettier integration to disable conflicting formatting rules
- Turbo plugin to catch undeclared environment variables
Next.js Config (packages/eslint-config/next.js)
For Next.js applications, here's an example that extends the base config with React and Next.js specific rules:
import js from '@eslint/js'
import { globalIgnores } from 'eslint/config'
import eslintConfigPrettier from 'eslint-config-prettier'
import tseslint from 'typescript-eslint'
import pluginReactHooks from 'eslint-plugin-react-hooks'
import pluginReact from 'eslint-plugin-react'
import globals from 'globals'
import pluginNext from '@next/eslint-plugin-next'
import { config as baseConfig } from './base.js'
/**
* A custom ESLint configuration for libraries that use Next.js.
*
* @type {import("eslint").Linter.Config[]}
* */
export const nextJsConfig = [
...baseConfig,
js.configs.recommended,
eslintConfigPrettier,
...tseslint.configs.recommended,
globalIgnores([
'.next/**',
'out/**',
'build/**',
'next-env.d.ts',
]),
{
...pluginReact.configs.flat.recommended,
languageOptions: {
...pluginReact.configs.flat.recommended.languageOptions,
globals: {
...globals.serviceworker,
},
},
},
{
plugins: {
'@next/next': pluginNext,
},
rules: {
...pluginNext.configs.recommended.rules,
...pluginNext.configs['core-web-vitals'].rules,
},
},
{
plugins: {
'react-hooks': pluginReactHooks,
},
settings: { react: { version: 'detect' } },
rules: {
...pluginReactHooks.configs.recommended.rules,
'react/react-in-jsx-scope': 'off',
},
},
]
This config adds:
- React and React Hooks linting
- Next.js specific rules (core web vitals, server component patterns)
- Automatic React version detection
- Modern JSX transform support (no need to import React)
Library Config (packages/eslint-config/library.js)
For non-React library packages, use a simpler config:
import { config as baseConfig } from './base.js'
/**
* ESLint configuration for library packages.
* Extends base config with library-specific considerations.
*/
export const libraryConfig = [
...baseConfig,
{
files: ['**/*.{ts,tsx,js,jsx}'],
rules: {
// Stricter rules for libraries that are consumed by other packages
// Can be customised based on library requirements
},
},
]
This keeps library linting focused on TypeScript best practices without React overhead.
Package Configuration (packages/eslint-config/package.json)
The config package exports these presets via package.json:
{
"name": "@repo/eslint-config",
"version": "0.0.0",
"type": "module",
"private": true,
"exports": {
"./base": "./base.js",
"./library": "./library.js",
"./next-js": "./next.js"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@next/eslint-plugin-next": "^15.5.0",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-turbo": "^2.6.0",
"globals": "^16.5.0",
"typescript": "^5.9.2",
"typescript-eslint": "^8.46.3"
}
}
Note the "type": "module" - this enables ES modules for ESLint 9's flat config format.
Part 2: Package-Level Configs
Each package now has a minimal eslint.config.mjs that imports the appropriate preset:
Next.js apps:
import { nextJsConfig } from '@repo/eslint-config/next-js'
/** @type {import("eslint").Linter.Config} */
export default [
...nextJsConfig,
// Package-specific overrides if needed
]
Library packages:
import { libraryConfig } from '@repo/eslint-config/library'
/**
* ESLint configuration for this library package.
*/
export default [
...libraryConfig,
{
files: ['**/*.ts', '**/*.tsx'],
rules: {
// Library-specific rules here
},
},
]
This approach gives you:
- Single source of truth for core rules
- Package autonomy for specific overrides
- Easy updates - change the centralised config once, all packages benefit
Part 3: Root Orchestration Config (The Secret Sauce)
Here's where it gets interesting. To enable lint-staged to run from the monorepo root while respecting each package's configuration, create a root eslint.config.mjs that maps file patterns to the appropriate configs:
import { config as baseConfig } from '@repo/eslint-config/base'
import { nextJsConfig } from '@repo/eslint-config/next-js'
import { libraryConfig } from '@repo/eslint-config/library'
/**
* Example root ESLint configuration for the monorepo.
* This allows lint-staged to run from the root while respecting package-specific configs.
*/
export default [
// Base config applies to all files
...baseConfig,
// Next.js apps: map to your app package names
{
files: ['packages/{web,admin,dashboard}/**/*.{js,mjs,cjs,ts,tsx}'],
ignores: ['**/*.config.{js,mjs,cjs}'],
},
...nextJsConfig.map((config) => ({
...config,
files: ['packages/{web,admin,dashboard}/**/*.{js,mjs,cjs,ts,tsx}'],
})),
// Library packages: map to your library package names
{
files: ['packages/{shared,ui,utils}/**/*.{js,mjs,cjs,ts,tsx}'],
ignores: ['**/*.config.{js,mjs,cjs}'],
},
...libraryConfig.map((config) => ({
...config,
files: ['packages/{shared,ui,utils}/**/*.{js,mjs,cjs,ts,tsx}'],
})),
]
This orchestration config:
- Applies base rules globally
- Maps Next.js config to specific packages using file glob patterns
- Maps library config to non-React packages
- Respects package boundaries - each package gets the right rules
Integration with Husky and lint-staged
With the root config in place, configure lint-staged in the root package.json:
{
"scripts": {
"lint": "turbo lint",
"lint:staged": "lint-staged",
"format:staged": "prettier --write"
},
"lint-staged": {
"*.{ts,tsx,js,jsx}": [
"eslint --fix",
"prettier --write"
],
"*.{json,md,yml,yaml,css}": [
"prettier --write"
]
}
}
And the Husky pre-commit hook (.husky/pre-commit):
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
echo "π Running lint-staged on changed files..."
npx lint-staged
When you commit files:
- Husky triggers the pre-commit hook
- lint-staged identifies changed files
- ESLint runs using the root config
- The root config maps files to their package-specific rules
- Only changed files are linted (blazingly fast β‘)
Turbo Configuration
Add the ESLint config package to turbo.json as a global dependency:
{
"globalDependencies": [
"eslint.config.*",
"packages/eslint-config/**",
".prettierrc*"
],
"tasks": {
"lint": {
"dependsOn": ["^build"],
"cache": true
}
}
}
This ensures that when the ESLint config changes, Turborepo invalidates the cache for all packages.
Migrating and Expected Results
Migration involves creating the centralised config package, removing old .eslintrc.json and .eslintignore files across packages, and setting up the root orchestration config to map file patterns to the appropriate presets. After migration, you'll see a net reduction in lines of code, faster commits, and a single source of truth for linting rules across your entire monorepo with Turborepo caching enabled.
Quantitative Improvements
- Lots of lines removed from package.json files
- Significantly smaller package-lock.json (fewer duplicate dependencies)
- Multiple config files deleted (.eslintrc.json and .eslintignore files)
- Net negative line changes across the monorepo
Qualitative Improvements
- Single source of truth - Update rules once, apply everywhere
- Faster commits - lint-staged only checks changed files
- Turborepo caching - Lint results cached across runs
- Modern ESLint 9 - Flat config format, better performance
- Package autonomy - Each package can still override rules
- Easier onboarding - New packages just import a preset
Developer Experience
Before:
# Commit took 15-30 seconds as it linted the entire monorepo
git commit -m "fix: small bug"
# ... waiting ...
After:
# Commit takes 1-3 seconds, only lints changed files
git commit -m "fix: small bug"
# β¨ Done!
Key Takeaways
- Centralize, don't duplicate - A shared config package is the foundation
- Compose, don't copy - Use preset configs and layer on top
- Map at the root - Orchestrate file patterns to configs for tooling
- Leverage Turborepo - Cache everything, including lint results
- Migrate incrementally - You can adopt this pattern package by package
Gotchas and Tips
TypeScript Resolution
If TypeScript can't resolve your config package, add it to tsconfig.json:
{
"compilerOptions": {
"paths": {
"@repo/eslint-config/*": ["./packages/eslint-config/*"]
}
}
}
ESLint 9 vs ESLint 8
ESLint 9 requires the flat config format. If you're on ESLint 8, you can still use this approach but you'll need to:
- Use
.eslintrc.jsinstead ofeslint.config.mjs - Export CommonJS modules instead of ES modules
- Adjust the config structure to the old format
Prettier Conflicts
Always include eslint-config-prettier as the last config in your array to ensure it disables conflicting rules:
export const config = [
js.configs.recommended,
...tseslint.configs.recommended,
eslintConfigPrettier, // β Must be last
]
Package-Specific Overrides
To add package-specific rules, just append to the config array:
import { nextJsConfig } from '@repo/eslint-config/next-js'
export default [
...nextJsConfig,
{
rules: {
// This package allows console.log
'no-console': 'off',
},
},
]
Debugging Config Application
To see which config is applied to a file:
# Check effective config for a specific file
npx eslint --print-config path/to/file.ts
Conclusion
Modernizing ESLint configuration in a Turborepo monorepo using a centralised approach is a game-changer. By creating a shared config package, leveraging ESLint 9's flat config format, and implementing a root orchestration layer, you can achieve a setup that's:
- Maintainable - Change rules in one place
- Fast - lint-staged + Turborepo caching
- Flexible - Packages can still customize
- Scalable - Easy to add new packages
If you're managing a Turborepo monorepo with multiple packages and struggling with ESLint configuration sprawl, I highly recommend this approach. The initial migration takes a few hours, but the long-term maintainability benefits are well worth it.
The key innovation is the root orchestration config that maps file patterns to package specific configurations. This enables tools like lint-staged and Husky to run from the root while respecting each package's linting rules - giving you the best of both worlds: centralised tooling with decentralised configuration. text in bold



