CI catching a lint error ten minutes after you push is annoying. A pre-commit hook catches it in two seconds, before the commit exists.
- Husky installs Git hooks that are committed with the repo, so the whole team gets them.
- lint-staged runs your tools only on staged files, so the hook stays fast no matter how big the project is.
Step 1: Install
pnpm add -D husky lint-staged pnpm exec husky init
husky init does two things:
- adds
"prepare": "husky"topackage.json, so hooks install onpnpm install; - creates
.husky/pre-commitwith a sample command.
Step 2: Point the hook at lint-staged
Replace the contents of .husky/pre-commit with:
pnpm exec lint-staged
Step 3: Configure lint-staged
In package.json:
{ "lint-staged": { "*.{ts,tsx,js,jsx}": ["eslint --fix", "prettier --write"], "*.{json,md,mdx,css,yml}": ["prettier --write"] } }
lint-staged passes the matching staged file paths to each command, then re-stages anything the tools changed. If a command exits non-zero, the commit is aborted and your changes are left as they were.
Step 4: Try it
echo "const x = 1;;" >> src/tmp.ts git add src/tmp.ts git commit -m "test hook"
You should see lint-staged run, fix or reject the file, and either commit or stop.
Adding a type check
tsc can't check single files correctly: it needs the whole project's types. Use a function in a JS config so it runs once, ignoring the file list:
// lint-staged.config.mjs export default { "*.{ts,tsx}": [ "eslint --fix", "prettier --write", () => "tsc --noEmit", ], "*.{json,md,mdx,css}": ["prettier --write"], };
If tsc takes more than a few seconds on your project, leave it to CI. A slow hook gets skipped with --no-verify, and then it's worth nothing.
Check commit messages too
A commit-msg hook can enforce Conventional Commits:
pnpm add -D @commitlint/cli @commitlint/config-conventional echo "export default { extends: ['@commitlint/config-conventional'] };" > commitlint.config.mjs echo 'pnpm exec commitlint --edit "$1"' > .husky/commit-msg
Now git commit -m "stuff" is rejected, and git commit -m "fix: handle empty cart" passes.
Catch secrets before they're committed
Add a secret scanner to the pre-commit hook so a stray API key never reaches history:
# .husky/pre-commit pnpm exec lint-staged gitleaks protect --staged --no-banner
That's far easier than removing a leaked .env from Git history later.
Keep it fast
- Run tools on staged files only. That's the whole point of lint-staged.
- Don't run the full test suite in pre-commit. Run related tests at most:
vitest related --run. - Use
eslint --cache. - Aim for under five seconds.
Skipping hooks
git commit --no-verify -m "wip" HUSKY=0 git rebase main # disable Husky for one command
Because anyone can skip hooks, CI must run the same checks. Hooks are for fast feedback. CI is what actually enforces the rules.
Monorepos
Put Husky at the repo root, where .git lives. lint-staged supports a config file per package and picks the closest one for each staged file.