The short answer: one shared base config, plus the smallest possible set of per-package overrides — and a CI run scoped to the packages that actually changed. A single flat root config stops fitting the moment one package is a React app and another is a Node CLI. Fully independent configs per package fit perfectly on day one and drift into ten different definitions of "our style" by month six.
Monorepo linting fails in two distinct ways, and they pull in opposite directions. Understanding which one is hurting you tells you which lever to pull.
The two failure modes
Config drift. Every package owns its own rules, so a rule tightened in one package never reaches the others. Code moved between packages suddenly fails checks it passed yesterday. New packages copy whichever neighbour someone happened to look at, and the copied config ages independently. Nobody can answer "what are our rules?" without opening a dozen files.
Runtime. A single root config that lints the entire repo on every pull request means a one-line change in one package pays for analysing everything. The cost grows with the repo, so the check gets slower every month until someone quietly removes it from the required-checks list — which is how enforcement dies in practice.
A good setup fixes both at once: rules converge on one place, execution diverges to only the code in scope.
The three config models
| Model | How rules are defined | Fits when | Fails when |
|---|---|---|---|
| Single root config | One config at the repo root covering everything | All packages share a language, framework, and runtime | Packages differ — you accumulate path-specific exceptions inside one enormous file |
| Base + overrides | Shared base package that each package extends, with small local overrides | Almost always — the default recommendation | Overrides go unpoliced and quietly become forks of the base |
| Fully independent | Each package configures itself from scratch | Genuinely unrelated projects that share only a repo | You want any consistency at all across packages |
Most teams start at single-root because it's the path of least resistance, hit the first "but this package is different" exception, and then face the choice. Take the base-and-overrides route deliberately rather than by accumulating exceptions until the root config is unreadable.
Building the base-plus-overrides layer
Publish your rules as an internal shared config — a package inside the monorepo (something like packages/lint-config) that every other package extends. The mechanics vary by tool: ESLint's flat config composes exported arrays of config objects; Ruff and similar tools support extending a base configuration file; Biome layers a root config with per-directory files. The pattern is the same everywhere: one file defines the rules, everything else references it.
Two design rules keep the layer healthy:
Publish variants, not one config with escape hatches. Export a small number of named presets — a base, plus one per package archetype (browser app, server service, library, test utilities). A package picks a preset. When a package needs something that isn't in a preset, that's a signal to either add a preset or fix the package, not to add a local exception.
Make overrides local and narrow. A per-package override should adjust the environment (globals, module system, file patterns) rather than redefine style or correctness rules. If you find severity changes for the same rule in several packages, the rule belongs in the base at the severity everyone actually wants.
Set a review expectation to match: the shared config is the code your whole repo depends on, so treat changes to it as a real change with real reviewers. Local overrides should be conspicuous enough that they invite the question "why does this package need this?"
Scope the run: lint only what changed
The runtime problem is solved separately from the config problem, and it has two standard approaches.
Changed-files scoping. Compute the files changed against the merge base and pass only those to the linter. It's simple, works with any tool, and is the fastest thing to adopt. Its limitation is real, though: it misses cases where changing package A breaks a lint rule in package B that imports it.
Affected-graph scoping. Monorepo task runners such as Nx, Turborepo, and Bazel build a dependency graph and run tasks for the changed packages plus everything that depends on them. This is the correct scope for a monorepo, and the graph is reusable for tests and builds, not just linting.
Layer caching on top of either. Most linters support a cache file that skips unchanged files between runs, and the task runners keep their own task-level caches. Persist the cache between CI runs where your platform allows it.
One protection is worth keeping regardless: a scheduled full-repo lint on the default branch. Scoped runs will eventually miss something — a rule change that invalidates old code, a file nobody has touched since the rule landed. A nightly or weekly full run catches that drift without slowing down pull requests. Everything else about wiring the gate itself is covered in linting in CI.
Keep the editor and CI in agreement
The most-reported monorepo lint complaint isn't slowness — it's an editor showing different results than CI. Three causes account for most of it:
- Config resolution. Some tools resolve configuration relative to the file being linted, others relative to the working directory. Editors often open the repo root while CI runs inside a package. Check how your tool resolves config and make both contexts produce the same answer.
- Multiple tool versions. Independent per-package dependency versions mean two packages can run different linter releases with different default rule behaviour. Pin the linter and its plugins in one place — usually the shared config package or a root-level dependency — so there is exactly one version in the repo.
- Ignore files. Generated output, build artifacts, and vendored code must be excluded identically in the editor, in hooks, and in CI. Divergent ignore rules produce findings that appear in one place and not another.
The invariant to protect: same tool, same version, same config, same exclusions, everywhere. If a check can produce a different result locally than on the server, developers will learn to distrust whichever one is less convenient.
Rolling this onto an existing monorepo
Do it package by package rather than repo-wide. Introduce the shared config, migrate one package to it, and confirm the results are what you expected before continuing. Where a package produces a wall of findings, adopt at warning severity first and promote to error once it is clean — an approach that keeps the pipeline green while the backlog shrinks.
Track one number: how many packages still carry local overrides that aren't purely environmental. Driving that toward zero is the whole point of the exercise.
FAQ
Should each package have its own linter dependency?
No. Keep one version of the linter and its plugins for the whole repo. Independent versions produce inconsistent results between packages and make upgrades a package-by-package slog.
How do I lint only the packages that changed?
Either diff against the merge base and pass the changed files to the linter, or use a monorepo task runner that computes an affected-package graph. The graph approach is more correct because it also covers packages that depend on what changed.
Is a root config ever the right answer?
Yes — for a monorepo where every package is the same kind of thing (all Node services, all libraries with the same target). Add the base-plus-overrides layer when the first genuine exception appears, not before.
What belongs in a per-package override?
Environment facts: globals, module system, file patterns, framework-specific plugin settings, and test-file exceptions. Style and correctness rules belong in the shared base so they apply everywhere.
How do I stop the shared config from becoming a bottleneck?
Give it explicit owners and a fast review path, and add presets rather than one-off exceptions. A shared config people can't change quickly is one they'll route around with local overrides.
Config layering and run scoping are decisions you make once and live with for years, and tools differ substantially in how well they support both. Compare linters and formatters on Lintense — criteria-first comparisons covering configuration model, monorepo support, and speed on large repositories — before you standardise your repo on one.