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
textpackages/ 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:
jsexport { 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 (
ignorePatternsfor.next,dist, generated clients). - Temporary
warndowngrades 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-dependenciesneeds thepackageDiroption aimed at the rightpackage.json, or every workspace package becomes a false positive festival.
Example adjustment that has saved me hours:
jsrules: { '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:
jsoverrides: [ { 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-prettierso 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.