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"]
} # 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"
} 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.
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.
Related Jump to heading
- Lint-Staged Formatting Automation — the parent topic.
- Speeding Up Slow lint-staged Runs — keeping a monorepo hook fast.
- lint-staged for Python and Go Projects — non-JavaScript packages in the same setup.
- Path-Based CODEOWNERS in a Monorepo — who owns each package’s config.