Setting Up Shared ESLint Configs in Workspaces
A shared ESLint config keeps every package in a monorepo on the same rules — but the first time you run eslint from a nested package directory, it often cannot find that config at all. The cause is almost always a missing exports field on the config package or a stale legacy .eslintrc. This guide centralizes a flat config across pnpm, npm, and Yarn workspaces and clears the resolution failures that block CI.
Exact symptoms and error messages
ESLintError: Cannot find module '@myorg/eslint-config' or its corresponding type declarations.
A secondary form shows up when ESLint runs from a nested package during CI or local development:
Oops! Something went wrong! :(
ESLint couldn't find the config "@myorg/eslint-config" to extend from.
The config "@myorg/eslint-config" was referenced from the config file in
"/repo/packages/web-app/eslint.config.js".
Root cause analysis
ESLint's flat config (eslint.config.js) resolves modules relative to the current working directory, and in a workspace that resolution fails for four recurring reasons:
- The shared config package lacks an
exportsfield, so Node's resolver ignores the symlinked package innode_modules. - The consumer declares the dependency without the
workspace:*protocol, so the virtual dependency graph never links it. - A legacy
.eslintrc.*file or cache lingers and collides with flat-config resolution. - Incorrect
files/ignoresglobs in the shared config exclude the consumer's source tree.
Mapping module resolution correctly across isolated package boundaries is exactly what the Workspace Configuration Deep Dive covers, and it is the prerequisite for any shared tooling. How and where you actually invoke eslint — at the root or per package — is a script-placement decision covered in Root-Level vs Package-Level Scripts.
The core difficulty is that a shared ESLint config in a workspace must be resolvable from every package that extends it, and the resolution rules differ between the legacy .eslintrc cascade and the modern flat config. Under flat config (eslint.config.js), a package imports the shared config as a normal JavaScript module, so it must be a declared dependency of the consuming package or resolvable through the workspace; under the legacy extends mechanism, ESLint resolves the config name through node_modules, which a strict, symlinked layout can make fail if the shared config is not a declared dependency.
The second common cause of trouble is plugin and parser resolution. ESLint plugins referenced by a shared config must be resolvable from where ESLint runs, and in a workspace with a strict layout a plugin declared only in the shared config's package may not be visible to the consuming package. This is the phantom-dependency problem in a new guise: a config that works when everything is hoisted flat breaks under strict resolution because the plugin was never declared where it is actually needed.
Resolution and configuration patch
Step 1 — Declare the workspace dependency in each consuming package so the symlink exists:
{
"devDependencies": {
"@myorg/eslint-config": "workspace:*"
}
}
Step 2 — Expose the entry point with an exports field on the shared package — this is the single most common missing piece:
{
"name": "@myorg/eslint-config",
"type": "module",
"main": "./eslint.config.js",
"exports": {
".": "./eslint.config.js"
}
}
Step 3 — Implement the flat config in the shared package, exporting a default array (never CommonJS module.exports in an ESM workspace):
// packages/eslint-config/eslint.config.js
import js from "@eslint/js";
import tseslint from "typescript-eslint";
export default [
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ["**/*.ts", "**/*.tsx"],
rules: {
"@typescript-eslint/no-explicit-any": "warn"
}
}
];
Step 4 — Extend it in each consumer at the package root:
// packages/web-app/eslint.config.js
import sharedConfig from "@myorg/eslint-config";
export default [
...sharedConfig,
{
files: ["src/**/*.{ts,tsx}"],
rules: {
"no-console": "warn"
}
}
];
Step 5 — Clear stale state so legacy configs and caches stop interfering:
rm -f .eslintrc.js .eslintrc.json .eslintrc
pnpm install --force
rm -rf .eslintcache
Publish the shared config as a workspace package that consuming packages depend on, and have each package's flat config import it:
// packages/config-eslint/index.js — the shared config package
import js from '@eslint/js';
export default [js.configs.recommended, { rules: { 'no-console': 'warn' } }];
// packages/ui/eslint.config.js
import shared from '@acme/config-eslint';
export default [...shared, { files: ['src/**/*.ts'] }];
Declare @acme/config-eslint as a dev dependency of each consuming package with the workspace: protocol, and declare the plugins the shared config uses as dependencies of the config package so they resolve wherever it is imported.
CLI validation and debug commands
# Run from the consumer dir with debug tracing; confirm the shared config loads
cd packages/web-app
npx eslint --debug . 2>&1 | grep -E "Loading|Resolved"
# Expected: lines resolving @myorg/eslint-config, no fallback to .eslintrc
# Confirm Node can resolve the package through the workspace symlink
node -e "import('@myorg/eslint-config').then(m => console.log('ok', Array.isArray(m.default)))"
# Verify the symlink exists in node_modules
ls -l node_modules/@myorg/eslint-config
Prevention and CI/CD guardrails
- Pin
eslint,@eslint/js, andtypescript-eslintto identical versions across all workspaces so version drift never breaks shared-config resolution. - Add a CI check that fails if any
.eslintrc.*file is committed — ESLint v9+ deprecates legacy configs and mixed formats resolve unpredictably. - Always run
eslintfrom the target package directory, or define a root config with explicitfiles: ["packages/**/src/**/*"]globs so nested packages are not skipped. - Keep the
exportsfield on the shared config under review; removing it silently breaks every consumer. - Standardize where lint runs across the repo per Root-Level vs Package-Level Scripts so the command is consistent in CI.
- Ship the shared config as a workspace package each consumer declares as a dependency, not an ambient file.
- Declare every plugin the shared config references as a dependency of the config package.
- Prefer flat config (
eslint.config.js) so config resolution follows normal module resolution. - Run ESLint in CI from each package so a resolution failure surfaces there, not only on one developer's machine.
Flat config versus the legacy cascade in a workspace
The move from the legacy .eslintrc cascade to flat config changes how a shared config resolves, and understanding the difference prevents most workspace ESLint pain. The legacy cascade walked up the directory tree merging .eslintrc files and resolved extends names and plugins through node_modules relative to the linted file — a model that interacts badly with a strict, symlinked layout because a plugin declared in the shared config's package may not be reachable from the consuming package. Flat config replaces this with explicit JavaScript imports: a package's eslint.config.js imports the shared config as a module, so resolution follows ordinary module rules and a declared dependency simply works.
For a workspace, flat config is the more robust choice precisely because it makes the dependency explicit. There is no ambient cascade to reason about and no name-based plugin resolution that can fail silently; a package imports exactly the config it declares, and if a plugin is missing the import fails loudly at the point of use. Migrating a workspace to flat config often surfaces the same latent undeclared-dependency issues that migrating to a strict package manager does — and fixing them, by declaring the shared config and its plugins where they are used, leaves the workspace's lint setup as explicit and reliable as its dependency graph.
Keeping shared config and its plugins in sync
A shared config is only useful if every package resolves the same version of it and its plugins, which is a version-consistency problem the workspace should solve deliberately. Because the shared config is a workspace package referenced through the workspace: protocol, every consuming package resolves the single local copy in development and a pinned published version afterward, so the config itself stays consistent. The plugins it depends on are declared as dependencies of the config package, so they too resolve to one version wherever the config is imported, rather than each consuming package pulling its own.
The failure to avoid is a plugin version declared inconsistently across packages, which can produce different lint results in different packages and confusing CI failures. Keeping all lint tooling — the config package, its plugins, the parser — versioned in one place and referenced everywhere, or centralized through a workspace catalog, ensures the whole workspace lints against the same rules with the same tooling. Running lint from each package in CI, rather than only from the root, then confirms that the shared setup resolves correctly everywhere and catches a package whose configuration has drifted.
Sharing the parser and TypeScript settings too
A shared ESLint config is rarely just rules; for a TypeScript workspace it also carries the parser, the type-aware linting settings, and often a reference to the type-checking project configuration. Bundling these into the shared config package keeps them consistent, but the type-aware settings need care because they resolve relative to each consuming package's tsconfig. Pointing the parser's project option at each package's own tsconfig.json — rather than a fixed path in the config package — lets a single shared config drive type-aware linting across packages that each have their own compiler settings.
The common failure is a shared config that hardcodes a project path, which then resolves correctly only in the package where that path happens to be valid. Making the config a function or using ESLint flat config's ability to compute settings per package avoids this, so the shared config adapts to each consumer's tsconfig location. Declaring the TypeScript ESLint parser and plugin as dependencies of the config package, and letting each package supply its own tsconfig reference, is what lets one shared config deliver consistent, type-aware linting across a heterogeneous workspace without each package reconfiguring the parser.
Frequently Asked Questions
How do I handle legacy .eslintrc plugins in a flat-config workspace?
Wrap them with the @eslint/eslintrc compatibility utility (FlatCompat), or migrate to their flat-config equivalents. ESLint v9+ does not auto-convert legacy plugins; load them explicitly via import in eslint.config.js.
Why does ESLint ignore my shared config when running from the monorepo root?
Flat config resolves relative to the working directory. Running from the root without a root-level eslint.config.js or proper files globs causes ESLint to skip nested packages. Run from the target package directory, or add a root config with explicit files: ["packages/**/src/**/*"] patterns.
Can I override specific rules per workspace without duplicating the whole config?
Yes. Spread the shared config array first, then append a config object with targeted files globs and rule overrides. In the flat-config cascade, the last matching object wins.
How do I make TypeScript type definitions resolve for the shared config?
Install typescript-eslint and @eslint/js as devDependencies in the consumer, and add "types": "./eslint.config.d.ts" to the shared package.json if you ship custom declarations for the config.
Why does my shared ESLint config fail to resolve a plugin under pnpm?
The plugin is declared in the shared config's package but not reachable from the consuming package under pnpm's strict layout. Declare the plugin as a dependency of the shared config package so it resolves wherever the config is imported, rather than relying on hoisting.
Should I use flat config or the legacy .eslintrc in a workspace?
Flat config. It resolves a shared config as an explicit module import following normal resolution, which is far more robust in a strict, symlinked workspace than the legacy cascade's name-based extends and plugin resolution that can fail silently.
How do I keep the shared config consistent across packages?
Ship it as a workspace package referenced with the workspace: protocol so every package resolves one copy, declare its plugins as dependencies of that package, and run lint from each package in CI to catch drift.
How do I share type-aware ESLint settings across packages with different tsconfigs?
Keep the parser and plugin in the shared config package, but let each package supply its own tsconfig reference for the parser's project option rather than hardcoding a path. Flat config's per-package computation makes one shared config adapt to each consumer's compiler settings.
Can one shared config lint both TypeScript and plain JavaScript packages?
Yes — flat config lets you scope rule sets to file patterns, so the shared config can apply type-aware rules to .ts files and plain rules to .js files, and each consuming package inherits the whole set through one import.
Where should the shared ESLint config package live?
Alongside your other workspace packages (for example under packages/config-eslint), published with the workspace: protocol so consumers depend on the local copy in development and a pinned version after publish — the same as any other internal package.
Related
- Workspace Configuration Deep Dive — the workspace protocol and module-resolution model this config depends on.
- Root-Level vs Package-Level Scripts — deciding where the lint command lives and runs.
- Setting Up npm Workspaces for Small Teams — the baseline workspace this shared config plugs into.