Logo

Front End Development Tutorials, Tips, and Tricks


Managing ESLint, Prettier, & Lint Staged in a Turborepo Monorepo

7th December, 2025

In this tutorial, I walk you through a modern ESLint, Prettier, & Lint-Staged setup for Turborepo monorepos.

Managing ESLint, Prettier, & Lint Staged in a Turborepo Monorepo

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.json files in every package
  • Separate .eslintignore files 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:

  1. Centralised ESLint config package (@repo/eslint-config)
  2. Composable preset configurations (base, Next.js, library)
  3. 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 (.js instead of .json)
  • TypeScript support via typescript-eslint v8.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:

  1. Applies base rules globally
  2. Maps Next.js config to specific packages using file glob patterns
  3. Maps library config to non-React packages
  4. 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:

  1. Husky triggers the pre-commit hook
  2. lint-staged identifies changed files
  3. ESLint runs using the root config
  4. The root config maps files to their package-specific rules
  5. 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

  1. Centralize, don't duplicate - A shared config package is the foundation
  2. Compose, don't copy - Use preset configs and layer on top
  3. Map at the root - Orchestrate file patterns to configs for tooling
  4. Leverage Turborepo - Cache everything, including lint results
  5. 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.js instead of eslint.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


More Posts…

CSS Sticky Footer with Flexbox and Grid

CSS Sticky Footer with Flexbox and Grid

8th April, 2019

In this quick tutorial, we'll build a sticky footer layout two ways β€” using CSS Flexbox and Grid.

CSS Only Floated Labels with :placeholder-shown pseudo class

CSS Only Floated Labels with :placeholder-shown pseudo class

15th September, 2018

In this tutorial, we’re going to build a CSS only solution to the floated label technique using the :placeholder-shown pseudo class.

Let's Build an Infinity CSS Animating Loader!

Let's Build an Infinity CSS Animating Loader!

15th June, 2017

In this tutorial, we use CSS offset-paths in tandem with animations to create a infinity symbol loader.


dev.callmenick.com