Configuring lint-staged in a monorepo Jump to heading

In a single-package repository, lint-staged has one configuration and one set of tools. In a monorepo, packages differ: the web app uses Prettier and ESLint with React rules, the API uses a different ESLint config, a Python service uses Ruff, and the docs package uses a Markdown linter. One root configuration that tries to describe all of them becomes a long list of globs, each running a tool from the wrong working directory with the wrong config. lint-staged supports a better arrangement: a configuration file in each package, discovered automatically, with each package’s commands run in that package’s directory against only its staged files. This page sets that up with a single Git hook at the root, within lint-staged formatting automation.

When to use this approach Jump to heading

  • Your repository contains several packages with different linters, formatters or configurations.
  • A root lint-staged config has grown into a tangle of package-specific globs.
  • Linters pick up the wrong config because they run from the repository root.
  • Hooks are already managed at the root, for example with Husky shared across a monorepo.

Step 1 — Put one hook at the root Jump to heading

Git hooks live per repository, not per package, so there is exactly one pre-commit hook. It runs lint-staged once from the root; lint-staged then finds the package configurations.

npm install --save-dev lint-staged husky
npx husky init
printf 'npx lint-staged\n' > .husky/pre-commit

Step 2 — Add a configuration file in each package Jump to heading

Since version 13, lint-staged looks for configuration files closest to each staged file. A file in packages/web/ uses packages/web/.lintstagedrc.json; a file in services/billing/ uses that directory’s config. Commands run with the package directory as the working directory, so each tool finds the right local configuration.

// packages/web/.lintstagedrc.json
{
  "*.{ts,tsx}": ["eslint --fix", "prettier --write"],
  "*.css": "prettier --write"
}
// services/billing/.lintstagedrc.json
{
  "*.py": ["ruff check --fix", "ruff format"]
}
How lint-staged routes staged files in a monorepoThe single root hook runs lint-staged. For each staged file, lint-staged finds the nearest configuration file, groups files by that configuration, and runs each group's commands from the package's own directory so tools pick up local settings.pre-commit hookroot, runs onceStaged filesgit diff --cachedNearest configper fileGroup + cwdpackage directoryTools runlocal configs foundfiles with no config above them are simply not processed
# Verification: stage one file in each package and see which tasks run
git add packages/web/src/App.tsx services/billing/totals.py
npx lint-staged --debug 2>&1 | grep -E 'Running tasks|cwd'

Step 3 — Keep a root config only for repository-wide files Jump to heading

Some files belong to no package: root Markdown, CI configuration, shared scripts. A root .lintstagedrc.json covers them. Because the nearest config wins, package files never reach the root config, and there is no double-processing.

// .lintstagedrc.json (root)
{
  "*.md": "markdownlint --fix",
  ".github/workflows/*.yml": "actionlint",
  "scripts/*.sh": "shellcheck"
}
One root config against per-package configsA single root configuration needs package-specific globs and runs every tool from the root, so tools may load the wrong settings. Per-package configurations are discovered automatically, run in the right directory, and are owned by the package's team.Root config onlyPer-package configsglobspackages/web/**/*.ts …*.ts (local)working directoryrepo rootpackage directorytool config foundsometimes wrongpackage's ownownershipplatform teampackage teamthe root config shrinks to files that belong to no package

Step 4 — Use package-local tool versions Jump to heading

Each package may pin its own version of a tool. Run tools through the package manager so lint-staged uses the version installed for that package rather than whichever is first on the path.

// packages/legacy-admin/.lintstagedrc.json — pinned older ESLint for this package
{
  "*.js": "npm exec --workspace=legacy-admin -- eslint --fix"
}

With pnpm or Yarn workspaces, the equivalent is pnpm --filter legacy-admin exec eslint --fix or yarn workspace legacy-admin eslint --fix. Workspace-specific notes are in using Husky with pnpm and Yarn.

Step 5 — Mirror the same checks in CI Jump to heading

Hooks can be skipped, so CI must run the same checks. Run each package’s linters on the files a pull request changed in that package, using the same configuration files.

# CI: lint changed files per package using the package's own lint-staged config
git diff --name-only --diff-filter=ACMR "origin/$BASE_REF...HEAD" > changed.txt
npx lint-staged --diff="origin/$BASE_REF...HEAD" --no-stash

--diff makes lint-staged operate on files changed in a range instead of staged files, and --no-stash skips the backup that is pointless in CI. The CI side of hook enforcement is covered in mirroring local hook checks in server-side policy.

Where each config appliesPackage configurations handle files inside their package, with commands run in the package directory. The root configuration handles repository-wide files that belong to no package. CI runs the same configurations against a pull request's diff, so skipping the hook changes nothing.one source of truth per filepackages/*/.lintstagedrcpackage files, package cwdroot .lintstagedrcrepo-wide files onlyCI: lint-staged --diffsame configs, PR rangethe hook is for speed; CI is for enforcement

Step 6 — Check that every package is covered Jump to heading

The weakness of per-package configuration is silent omission: a newly added package without a .lintstagedrc file gets no checks at all, and nobody notices until its code drifts. A short CI check lists packages that contain source files but have no configuration above them.

#!/bin/sh
# ci/check-lintstaged-coverage.sh — every package with source code has a lint-staged config
missing=0
for dir in packages/* services/*; do
  [ -d "$dir" ] || continue
  git ls-files "$dir" | grep -qE '\.(ts|tsx|js|py|go)$' || continue
  ls "$dir"/.lintstagedrc* "$dir"/lint-staged.config.* >/dev/null 2>&1 && continue
  echo "no lint-staged config in $dir"; missing=1
done
exit $missing

Run it on pull requests that add directories. It costs a second and turns a gap that would otherwise go unnoticed for months into a failed check on the pull request that created it.

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Does lint-staged run packages in parallel? Jump to heading

Yes. Each configuration’s tasks run concurrently by default, and files are grouped per configuration. Very large staged sets can be throttled with --concurrent to limit load.

What if a file matches no configuration? Jump to heading

It is ignored. That is usually correct — lock files, images, generated code — but check that a newly added package has a configuration, or its files will silently skip linting.

Can I share settings between package configs? Jump to heading

Use a JavaScript config file (.lintstagedrc.mjs) that imports a shared base from a workspace package, then extends it. That keeps common rules in one place while each package adds its own.