Back to core workflows Fix dependency resolution Tune package metadata Jump to monorepo patterns

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:

  1. The shared config package lacks an exports field, so Node's resolver ignores the symlinked package in node_modules.
  2. The consumer declares the dependency without the workspace:* protocol, so the virtual dependency graph never links it.
  3. A legacy .eslintrc.* file or cache lingers and collides with flat-config resolution.
  4. Incorrect files / ignores globs 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.

How a consumer package resolves the shared ESLint config A consumer eslint.config.js imports the shared package, which resolves through the workspace symlink only when the shared package declares an exports field. consumer eslint.config.js workspace symlink workspace:* @myorg/eslint-config exports: "." → config import resolve no exports field Cannot find module
The import resolves through the workspace symlink only when the shared package exposes an exports field; without it, Node returns Cannot find module.

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, and typescript-eslint to 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 eslint from the target package directory, or define a root config with explicit files: ["packages/**/src/**/*"] globs so nested packages are not skipped.
  • Keep the exports field 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