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.
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
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.
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.
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.