Skip to content

Pre-commit Hooks: Husky vs lint-staged vs nano-staged

Comparing husky, lint-staged, and nano-staged on DX and speed, what each owns, where hooks become a tax, and the stack I keep for frontend monorepos.

5 min read
Husky
lint-staged
nano-staged
Git Hooks
DX

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:

  1. Format the files I touched (or fail if they are dirty after format).
  2. Lint those files with the same rules CI uses.
  3. Finish fast enough that people do not run --no-verify as 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 --fix with 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

ConcernHuskylint-stagednano-staged
RoleInstall/run Git hooksRun tasks on staged filesSame job, lighter footprint
Competing withOther hook installersnano-stagedlint-staged
Ecosystem / examplesHugeHugeSmaller but adequate
Config expressivenessN/A (shell scripts)Mature, flexibleIntentionally lean
When I pick itDefault installerDefault runnerWhen 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

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