Skip to content

Setting Up ESLint + Prettier in a Monorepo

Shared ESLint and Prettier configs as packages, keeping package boundaries clean so lint stays a maintainable contract, not a copy-paste museum.

4 min read
ESLint
Prettier
Monorepo
Linting
Tooling

Lint config multiplies in a monorepo. The first app gets a careful .eslintrc and .prettierrc. The second app gets a copy. The third app gets a copy of the copy with one rule quietly different. Three months later, "why does CI fail only in admin?" becomes a scavenger hunt.

I treat ESLint and Prettier as shared products: versioned inside the workspace, consumed explicitly, changed in one place.

Package the config

text
packages/ config-eslint/ package.json index.js config-prettier/ package.json index.js

@acme/config-prettier can be tiny:

js
/** @type {import('prettier').Config} */ const config = { singleQuote: true, trailingComma: 'all', printWidth: 80, useTabs: true, }; export default config;
json
{ "name": "@acme/config-prettier", "version": "0.0.0", "private": true, "type": "module", "exports": { ".": "./index.js" } }

Apps then point at it:

json
{ "prettier": "@acme/config-prettier" }

Or a local prettier.config.js:

js
export { default } from '@acme/config-prettier';

Same idea for ESLint, one package exporting a shared config object (or, later, flat config array) that apps extend.

ESLint config as a dependency graph

What belongs in the shared package:

  • Base language options (ecmaVersion, sourceType).
  • Import hygiene rules that apply everywhere.
  • TypeScript-eslint shared baselines for TS packages.
  • React hooks / JSX rules for UI packages.

What stays local to an app:

  • Next.js / framework-specific extends.
  • Repo paths (ignorePatterns for .next, dist, generated clients).
  • Temporary warn downgrades during a migration.

A sketch with the classic config style still common in early 2025 codebases:

js
// packages/config-eslint/index.js /** @type {import('eslint').Linter.Config} */ module.exports = { root: false, env: { es2022: true, browser: true, node: true }, parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, extends: ['eslint:recommended', 'plugin:import/recommended', 'prettier'], rules: { 'import/order': [ 'error', { 'alphabetize': { order: 'asc', caseInsensitive: true }, 'newlines-between': 'always', }, ], 'no-console': ['warn', { allow: ['warn', 'error'] }], }, };
js
// apps/web/.eslintrc.cjs module.exports = { root: true, extends: ['@acme/config-eslint', 'plugin:@next/next/core-web-vitals'], ignorePatterns: ['.next/', 'node_modules/'], };

"prettier" in extends (via eslint-config-prettier) is non-negotiable if you run both tools. I want formatting owned by Prettier and correctness/style owned by ESLint, not two tools fistfighting over semicolons.

Package boundaries that keep lint honest

Monorepo lint fails in subtle ways when imports cross packages carelessly:

  • An app importing a package deep path (@acme/ui/src/button.tsx) bypasses the public export and often bypasses the lint assumptions of that package.
  • A shared config that enables import/no-extraneous-dependencies needs the packageDir option aimed at the right package.json, or every workspace package becomes a false positive festival.

Example adjustment that has saved me hours:

js
rules: { 'import/no-extraneous-dependencies': [ 'error', { packageDir: [__dirname, path.join(__dirname, '../..')], devDependencies: [ '**/*.test.*', '**/*.config.*', '**/vitest.setup.*', ], }, ], }

Tune the packageDir list to your layout. The rule is worth it: it encodes "you must declare what you import," which pairs with pnpm's strictness from earlier in this series.

Scripts operators will actually run

Root:

json
{ "scripts": { "lint": "pnpm -r lint", "format": "prettier --write .", "format:check": "prettier --check ." } }

Per package:

json
{ "scripts": { "lint": "eslint . --max-warnings=0" } }

--max-warnings=0 in CI. Warnings that never fail the build become permanent weather.

Maintainability beats rule maximalism

A shared config with forty opinionated stylistic rules will fork the moment someone disagrees. I keep ESLint focused on bug-adjacent rules and clear consistency wins, and I let Prettier own aesthetics.

When we need an exception, we use targeted overrides, not a fork of the whole package:

js
overrides: [ { files: ['scripts/**/*.{js,mjs,cjs}'], rules: { 'no-console': 'off' }, }, ],

Version the shared packages with the workspace protocol (workspace:*). Bumping rules should be a deliberate PR that every app picks up on the next install, not a silent drift where web is three commits ahead of admin because someone edited a local .eslintrc "just for now."

CI and local must agree

Local pnpm lint and CI lint must resolve the same config packages. I pin the eslint/prettier majors in the shared packages and let apps depend on those packages, not on a second, slightly older eslint floating in an app devDependencies. Two ESLint versions in one monorepo is how "works on my machine" becomes a weekly standup item.

What good looks like

  • One prettier config package, one eslint config package (plus thin app extends).
  • eslint-config-prettier so the tools do not conflict.
  • Lint scripts identical in spirit across packages.
  • Changes to rules land in a dedicated PR with a short rationale, not buried in a feature branch.

This classic stack still works. It is also where migration pressure starts - especially once flat config becomes the default path and .eslintrc turns into legacy. That migration is next.