Lint in CI is necessary. Lint only in CI is how broken style and obvious bugs
land on main and become everyone else's morning. Pre-commit hooks close that
gap, until they make every commit feel like a release candidate.
The tooling names get mashed together in READMEs. They are not substitutes:
- Husky (or an equivalent hook runner) installs Git hooks.
- lint-staged / nano-staged run tasks on staged files, not the whole repo.
You pick a hook installer and a staged runner. Comparing "husky vs lint-staged" as if they compete is a category error that wastes an afternoon.
The job to be done
On pre-commit, I want:
- Format the files I touched (or fail if they are dirty after format).
- Lint those files with the same rules CI uses.
- Finish fast enough that people do not run
--no-verifyas a lifestyle.
I do not want typecheck of the entire monorepo, e2e suites, or a full
pnpm -r build on every commit. Those belong in CI or a slower pre-push if
you insist, and even then, carefully.
Husky: the hook installer
Husky's job is boring: make sure .git/hooks/pre-commit exists and runs a
script after pnpm install / npm prepare. Modern husky is thin, a
.husky/pre-commit file that calls your runner.
sh# .husky/pre-commit pnpm exec lint-staged
json{ "scripts": { "prepare": "husky" }, "devDependencies": { "husky": "^9.1.0", "lint-staged": "^15.4.0" } }
Alternatives exist (simple-git-hooks, leftover pre-commit frameworks from
other ecosystems). I stick with husky on Node repos because onboarding docs and
muscle memory are cheap. The important part is one prepare script so clones
get hooks without a wiki page nobody reads.
If hooks do not install in CI containers, good: CI should not need them. Gate hook install on local/dev if your environment is weird; do not debug husky inside GitHub Actions.
lint-staged: the default staged runner
lint-staged maps globs to commands and passes the staged file list:
js// lint-staged.config.js export default { '*.{js,jsx,ts,tsx,mjs,cjs}': ['eslint --fix', 'prettier --write'], '*.{json,md,mdx,yml,yaml,css}': ['prettier --write'], };
Or the package.json form:
json{ "lint-staged": { "*.{js,jsx,ts,tsx}": ["eslint --fix", "prettier --write"] } }
Why staged-only matters in a monorepo: running eslint . on every commit
re-lints untouched packages and trains people to skip hooks. Staged files keep
the feedback loop honest.
Gotchas I budget for:
- ESLint must accept file args.
eslint --fixwith paths is fine. A custom script that ignores argv and always lints.defeats the point. - Formatting rewrites need a re-stage. lint-staged handles re-adding fixed files in normal setups; broken custom scripts that format outside its control leave "I fixed it but Git still shows dirty" confusion.
- Partial staging is sharp. Staging hunk A while hunk B is dirty can produce surprising lint results. Teach the team or keep commits small.
nano-staged: the speed-oriented cousin
nano-staged aims at the same niche with a smaller/faster implementation and a similar config idea. I reach for it when:
- Commit hooks are measurably slow with lint-staged on large staged sets, and
- The config surface we need is simple (format + eslint on globs).
json{ "nano-staged": { "*.{js,ts,tsx}": ["eslint --fix", "prettier --write"], "*.{md,json}": ["prettier --write"] } }
sh# .husky/pre-commit pnpm exec nano-staged
I do not switch for ideology. I switch when profiling shows the runner overhead (or config features we do not use) is the bottleneck, not when ESLint itself is slow. If ESLint is the cost, fix the ESLint config (fewer rules, flat config sanity, ignore generated files) before swapping staged runners.
Comparison I actually use
| Concern | Husky | lint-staged | nano-staged |
|---|---|---|---|
| Role | Install/run Git hooks | Run tasks on staged files | Same job, lighter footprint |
| Competing with | Other hook installers | nano-staged | lint-staged |
| Ecosystem / examples | Huge | Huge | Smaller but adequate |
| Config expressiveness | N/A (shell scripts) | Mature, flexible | Intentionally lean |
| When I pick it | Default installer | Default runner | When hooks feel heavy and config is simple |
Default stack for my frontend monorepos: husky + lint-staged.
Swap lint-staged → nano-staged when the team agrees the config will stay boring and commit latency is a real complaint backed by timing, not gut feel.
Skip hooks entirely only for throwaway repos. For product work,
--no-verify should be an escape hatch for emergencies, not the culture.
Failure modes that make hooks toxic
Hooks that run the world. Typecheck + test + lint on pre-commit guarantees
--no-verify. Move heavy gates to CI.
Hooks that disagree with CI. If local eslint uses a different config path
than CI, hooks become busywork. Same binary, same config file, same
--max-warnings=0 philosophy.
Hooks without an uninstall story. New contributors on restricted machines
need a documented escape (HUSKY=0, or skip prepare) without shame, and a CI
net that still catches mistakes.
Secret scanning cosplay. Pre-commit is not your only secret control. Useful as a first filter; insufficient as a security program.
What I ship
textpackage.json # prepare → husky .husky/pre-commit # runs lint-staged (or nano-staged) lint-staged.config.js # eslint --fix + prettier --write on staged globs
CI still runs full-package lint and format check. Hooks are the fast local filter; CI is the source of truth for the whole tree.
Pre-commit tooling is successful when it is invisible on good days and loud on
bad ones, not when it becomes a second build system living in .git/hooks.