Sharing ESLint/Prettier Config Across a Monorepo

In a monorepo, duplicating ESLint and Prettier config into every package guarantees drift. This page centralizes them into a shared internal config package that each workspace extends, so one change updates every package — and inside a devcontainer, the pinned toolchain makes that shared config resolve identically for everyone.

The reason this matters is that a monorepo multiplies every inconsistency. If apps/web disables no-unused-vars while apps/admin treats it as an error, the same code passes lint in one package and fails in another, and reviewers waste time on rules that were never meant to differ. Copying an eslint.config.js from one package to the next feels harmless the first time, but each copy is a fork that ages independently: a rule tweak lands in three of five packages, a Prettier flag flips in the one someone touched most recently, and later nobody can say what the canonical style is. The shared-package approach removes the copies entirely, so one file defines the rules and every workspace points at it.

Reach for this pattern the moment you have a second package that should lint the same way as the first — that is, almost immediately in any real monorepo. The mental model is dependency inversion applied to configuration: instead of each package owning a full copy of the rules, the rules live in one internal package (@acme/eslint-config) that the others depend on, exactly like any other shared library. ESLint's flat config makes this composition explicit because a config file is just an array you can import and spread, and Prettier stays uniform because it reads a single config from the repo root. The devcontainer is what closes the loop: it pins the Node and package-manager versions so "extends the shared config" produces byte-identical results on every laptop and in CI, rather than merely similar ones.

Prerequisites

You need a monorepo with a workspace manager and a place for a shared config package.

  • A monorepo using pnpm/npm/yarn workspaces.
  • A shared internal package (e.g. @acme/eslint-config).
  • ESLint flat config and Prettier available at the root.

The workspace manager is the load-bearing prerequisite, because it is what lets @acme/eslint-config be resolved by name from every other package without publishing it to a registry. With pnpm or npm/yarn workspaces, the shared package is symlinked into each workspace's node_modules, so import shared from '@acme/eslint-config' resolves to the local source rather than a downloaded tarball. Give the shared package a real name in its package.json and, for flat config, an exports (or main) field that points at the config file — ESLint has to be able to import it as a module, not just find the folder.

The one detail people get wrong is forgetting to declare the shared package as an actual dependency of each consuming workspace. It is tempting to assume that because everything lives in the same repo, any package can import any other, but workspace resolution only creates the symlink when the dependency is listed in that package's package.json. Skip it and ESLint fails with a "cannot find module @acme/eslint-config" error in exactly the workspaces you forgot, which is why the verification step later walks every package rather than trusting that the root install wired things up.

Monorepo config prerequisitesA shared config package, extended per workspace, with one pinned toolchain.Shared package@acme/eslint-configExtendper workspaceRoot Prettiersingle configPinone toolchain

Step-by-Step Implementation

  1. Create a shared config package that exports the flat config.
// packages/eslint-config/index.js
export default [
  { rules: { 'no-unused-vars': 'error' } }
];

The package's index.js does a single job: it default-exports the flat-config array. Because flat config is a plain array of config objects rather than an opaque object graph, exporting it is nothing more than export default [...], and consumers can treat it as data — they import it and decide where in their own array it sits. Keeping the export minimal here (one rule for illustration; in practice your parser, plugin, and rule blocks) matters because everything a workspace inherits flows through this one module. Put the file behind a proper package name and exports entry so ESLint resolves @acme/eslint-config to this exact file; if the export shape is wrong — an object instead of an array, or a named export the consumers do not import — every downstream eslint.config.js silently inherits nothing.

  1. Extend it in each workspace so packages don't duplicate rules.
// apps/web/eslint.config.js
import shared from '@acme/eslint-config';
export default [...shared];

Each workspace's own eslint.config.js imports the shared array and spreads it into its exported config. The spread (...shared) is deliberate: it copies the shared config objects into this package's array while leaving room to append workspace-specific blocks afterward, so apps/web can add a React or browser-globals override without editing — or forking — the shared package. Order is significant in flat config, since later objects override earlier ones for overlapping files, which is exactly why the shared blocks come first and any local exceptions come last. If you instead reassigned or mutated shared, you would risk changing the array that every other workspace also imports; spreading into a fresh array keeps each package's overrides local to that package.

  1. Keep one Prettier config at the root so formatting is uniform.
{ "singleQuote": true, "semi": true }

Prettier deliberately has no extends mechanism the way ESLint does, so the right unit of sharing is a single config file at the repository root rather than a package. Prettier walks up the directory tree from each file it formats and stops at the first config it finds, which means a root-level .prettierrc (or the prettier key in the root package.json) applies to every file in every package as long as no package plants a competing config below it. Keeping the options here small and explicit — singleQuote and semi — avoids the trap of relying on defaults that can shift between Prettier major versions; write down the values you actually care about so formatting does not quietly change when the pinned version bumps. The failure mode this prevents is a stray .prettierrc inside one package overriding the root and reintroducing exactly the drift you set out to eliminate.

  1. Verify every workspace resolves the shared config.
pnpm -r exec eslint --print-config src/index.ts >/dev/null && echo "config resolves everywhere"

The verification step runs eslint --print-config in every workspace via pnpm -r exec, and choosing --print-config over an actual lint run is the point. It computes and prints the fully resolved configuration for a file without evaluating any source, so it fails immediately if a workspace cannot import @acme/eslint-config — the "cannot find module" case the prerequisites warned about — instead of masking a resolution failure behind lint output you might not read. Redirecting to /dev/null discards the resolved JSON because you only care whether the command exits zero; the && gate prints the confirmation only if the config resolved in all packages, since pnpm -r propagates a non-zero exit from any workspace. Run this after wiring up the shared dependency and again in CI, so a missing link is caught by the pipeline rather than a developer three commits later.

Monorepo config anatomyOne shared package, extended per workspace, with a root Prettier config and pinned tools.Shared config packagesingle source of rulesWorkspace extendsimport + spreadRoot Prettierone formatting configPinned toolchainsame versions everywhere

Common Pitfalls

Monorepo lint drift comes from duplicated config or version skew between packages.

Version skew is the subtler half of that sentence and the one a shared config package alone does not fix. If each workspace installs its own copy of eslint or prettier, the shared config is interpreted by whatever version happens to be hoisted for that package, and a rule that was renamed or a formatting default that changed between minor releases will produce different output even though the config text is identical. Pin ESLint, Prettier, and their plugins once at the workspace root and let the shared config assume that single version; inside the devcontainer the pinned Node and package manager make that assumption hold for everyone, so "same config" and "same tool" travel together instead of drifting apart.

The other trap is subtle because it involves resolution rather than a visible copy. When a workspace pulls in its own competing config file — a stray .eslintrc left over from a pre-flat-config migration, or a package-local .prettierrc someone added to silence one file — that nearer config wins, and the workspace quietly stops inheriting the shared rules while still appearing to be part of the shared setup. These files leave no obvious footprint in the shared package, so the only reliable way to catch them is the --print-config check across every workspace: if the resolved config for a package is missing the shared rules, something local is shadowing them. Treat any package-level ESLint or Prettier file as suspect and delete it unless it exists specifically to add an override on top of the shared base.

Monorepo triageA triage path from duplicated, drifting config to one shared source.Do packages duplicate lint rules?YESExtend a shared config packageSame ESLint/Prettier version everywhere?YESPin at the workspace rootOne config, all packages

SymptomRoot CauseRemediation
Rules differ between packagesConfig copied into each packageExtend one shared config package
Formatting differs across packagesMultiple Prettier configsKeep a single root Prettier config
Version skew between workspacesTools installed per packagePin one version at the root
Shared config not foundPackage not linked in the workspaceAdd it as a workspace dependency

Conclusion

Centralize once, extend everywhere. A shared internal config package plus a single root Prettier config means one edit updates every workspace, and pinning the toolchain in the devcontainer makes that shared config resolve identically for every developer and in CI. Duplication is the enemy; a shared package is the fix.

The strategic payoff is that lint and format rules stop being a per-package negotiation and become a versioned artifact you can reason about. When the rules live in @acme/eslint-config, changing them is a normal pull request against one package, reviewable in one diff, and every workspace picks up the change on the next install rather than through a manual sweep across a dozen eslint.config.js files. That turns a class of "why does this package lint differently" questions into a non-event, because the answer is always the same file. As the repo grows from two packages to twenty, the cost of the shared approach stays flat while the cost of the copy-paste approach grows with every new workspace.

This is the same pin-and-cache reproducibility theme that runs through the rest of a well-built devcontainer, applied to code style. Pinning the toolchain is what makes the shared config deterministic; caching the workspace install is what makes it fast; and centralizing the config is what makes it singular. Together they mean a fresh clone, a teammate's laptop, and the CI runner all resolve the same rules against the same tool versions and produce the same lint result — no "works on my machine" formatting diffs, no surprise rule failures after a background dependency bump. Shared config is not a cosmetic nicety here; it is one more input pinned so the whole environment stays identical everywhere it runs.

Central vs per-packageCentralize rules and versions; each workspace only extends, never duplicates.CentralizeShared ESLint packageRoot Prettier configOne toolchain versionPer workspaceExtend shared configMinimal overridesNo duplication

FAQ

How do I avoid copying lint config into every package? Publish a shared internal config package (for example @acme/eslint-config) that exports your flat config, and have each workspace's eslint.config.js import and spread it. One edit to the shared package updates every workspace, so there is nothing to keep in sync by hand. The package does not need to be published to a public registry — a workspace dependency is enough, because pnpm and npm/yarn workspaces symlink it into each consumer's node_modules under its real name. The only per-workspace file that remains is a two-line eslint.config.js that imports the shared array and spreads it, which is small enough that it cannot meaningfully drift.

Should Prettier config be per-package or at the root? At the root. Prettier has no composition mechanism like ESLint's extends, so a single root configuration is the cleanest way to keep formatting uniform across all packages. Per-package Prettier configs are how monorepo formatting drifts. Because Prettier resolves the nearest config by walking up the directory tree, a root config automatically applies to every package as long as none of them plants a competing config lower down. If you ever do need a genuine per-package exception, prefer a Prettier overrides block in the root config keyed by file glob rather than a second config file, so the exception stays visible in the one place everyone reads.

Why does the devcontainer help with monorepo consistency? Because it pins one Node and one package-manager version for everyone, so the shared config resolves against identical tooling. Without that, a package installed with a different local ESLint could interpret the shared config differently. The container makes 'shared' actually mean identical. It also removes the "works on my machine" formatting diff, where one developer's globally installed Prettier reformats files on save differently from the pinned version, producing noisy pull requests. With the toolchain baked into the image, the same eslint --print-config output appears on every laptop and on the CI runner.

How should the shared config handle TypeScript versus plain JavaScript packages? Keep the language-agnostic rules in the base array the shared package exports, then expose additional exports (or additional array entries a consumer can spread) for the TypeScript-specific parser and plugin blocks. A TypeScript package spreads both the base and the TypeScript layer, while a plain JavaScript package spreads only the base. This keeps a single source of rules without forcing the type-aware parser onto packages that do not need it.

Where should CI run the shared lint check in a monorepo? Run it against the whole workspace from the root — for example the same pnpm -r exec eslint pattern used to verify resolution — so every package is linted with the shared config on every pull request. Running per-package lint jobs in isolation is fine for speed, but at least one root-level job should exercise all workspaces together, because that is where a missing workspace dependency or a shadowing local config shows up. Pin the CI image to the same tool versions the devcontainer uses so results never diverge.