Husky & lint-staged (Pre-commit Hooks)
Run ESLint and Prettier automatically on staged files before each commit to catch issues early.
Recipe
Quick-reference recipe card — copy-paste ready.
# Install husky and lint-staged
npm install --save-dev husky lint-staged
# Initialize husky
npx husky init
# The init command creates .husky/pre-commit
# Edit it to run lint-staged
echo "npx lint-staged" > .husky/pre-commitWhen to reach for this: Any team project where you want to guarantee that committed code passes linting and formatting checks.
Working Example
# .husky/pre-commit
npx lint-staged// package.json
{
"scripts": {
"lint": "next lint",
"format": "prettier --write .",
"prepare": "husky"
},
"lint-staged": {
"*.{ts,tsx}": [
"eslint --fix --no-warn-ignored",
"prettier --write"
],
"*.{css,json,md}": [
"prettier --write"
]
}
}What this demonstrates:
- Husky hooks into Git's pre-commit event
- lint-staged runs ESLint and Prettier only on files that are staged (
git add) - TypeScript files get both linted and formatted; other files get formatted only
- The
preparescript ensures husky installs automatically afternpm install
Deep Dive
How It Works
- Husky uses Git hooks (
.husky/pre-commit) to run commands before each commit - lint-staged collects files from
git diff --stagedand passes them to the configured commands - Commands run in sequence per glob pattern — if ESLint fails, Prettier does not run, and the commit is blocked
- Fixed files are automatically re-staged so the commit includes the fixes
- The
preparescript runs afternpm install, ensuring hooks are set up for every developer
Variations
With Biome instead of ESLint + Prettier:
{
"lint-staged": {
"*.{ts,tsx,js,jsx,json,css}": [
"biome check --write --no-errors-on-unmatched"
]
}
}With type-checking (slower but thorough):
{
"lint-staged": {
"*.{ts,tsx}": [
"eslint --fix --no-warn-ignored",
"prettier --write"
]
}
}# .husky/pre-commit
npx lint-staged
npx tsc --noEmitNote: tsc --noEmit checks the entire project, not just staged files, because TypeScript needs full project context.
Commit message linting with commitlint:
npm install --save-dev @commitlint/cli @commitlint/config-conventional
echo "npx commitlint --edit \$1" > .husky/commit-msg// commitlint.config.js
export default { extends: ["@commitlint/config-conventional"] };Skipping hooks (escape hatch):
# When you really need to bypass (debugging, WIP commits)
git commit --no-verify -m "WIP: work in progress"TypeScript Notes
// lint-staged passes file paths to ESLint, which works with
// TypeScript files seamlessly as long as @typescript-eslint
// is configured.
// Note: ESLint --fix can auto-fix some TypeScript issues:
// - Remove unused imports
// - Add `type` keyword to type-only imports
// - Fix consistent-type-imports violationsGotchas
Things that will bite you. Each gotcha includes what goes wrong, why it happens, and the fix.
-
Hooks not running after clone — Git hooks are not committed to the repo; they live in
.git/hooks/. New developers need to runnpm install(which triggersprepare). Fix: Ensure"prepare": "husky"is in yourpackage.jsonscripts. -
ESLint warnings on ignored files — When lint-staged passes file paths to ESLint, files matching your ESLint ignores trigger warnings. Fix: Add
--no-warn-ignoredflag to the ESLint command in lint-staged config. -
Partial staging issues — If you stage only part of a file (
git add -p), lint-staged operates on the full file, which may include unstaged changes. Fix: Be aware of this limitation. For critical cases, commit the full file. -
Slow pre-commit hooks — Running type-checking (
tsc) on every commit can take 10 or more seconds on large projects. Fix: Runtsconly in CI. Keep pre-commit hooks fast by limiting them to ESLint and Prettier on staged files. -
CI does not run hooks — Git hooks are local only. CI environments do not execute pre-commit hooks. Fix: Always run lint and format checks in CI as a safety net. See Linting in CI/CD.
Alternatives
Other ways to solve the same problem — and when each is the better choice.
| Alternative | Use When | Don't Use When |
|---|---|---|
lefthook | You want faster hooks with parallel execution, written in Go | Husky + lint-staged works fine for your project |
| CI-only linting | You do not want to slow down local commits | You want instant feedback before pushing |
nano-staged | You want a smaller, faster alternative to lint-staged | You need advanced lint-staged features like custom resolvers |
| VS Code format-on-save | Solo developer, no CI | Team projects where consistency must be enforced |
FAQs
What does the "prepare" script do in package.json?
- It runs automatically after
npm install. - It ensures Husky's Git hooks are installed for every developer who clones the repo.
- Without it, new developers would not have pre-commit hooks until they manually run
npx husky init.
How does lint-staged know which files to process?
- It collects files from
git diff --staged(files you havegit add-ed). - It matches staged files against the glob patterns in your config.
- Only matching staged files are passed to the configured commands.
What happens if ESLint fails during the pre-commit hook?
- The commit is blocked and does not go through.
- Commands run in sequence per glob -- if ESLint fails, Prettier does not run.
- Fix the ESLint errors and try committing again.
How do I bypass the pre-commit hook in an emergency?
git commit --no-verify -m "WIP: work in progress"Use this sparingly for debugging or WIP commits. CI will still catch issues.
Gotcha: Why are hooks not running after a fresh clone?
- Git hooks live in
.git/hooks/, which is not committed to the repo. - New developers must run
npm install, which triggers thepreparescript. - Ensure
"prepare": "husky"is in yourpackage.jsonscripts.
Can I run type-checking in the pre-commit hook?
# .husky/pre-commit
npx lint-staged
npx tsc --noEmit- Yes, but
tsc --noEmitchecks the entire project, not just staged files. - This can take 10+ seconds on large projects.
- Consider running
tsconly in CI to keep hooks fast.
How do I use Biome with lint-staged instead of ESLint + Prettier?
{
"lint-staged": {
"*.{ts,tsx,js,jsx,json,css}": [
"biome check --write --no-errors-on-unmatched"
]
}
}Gotcha: What happens with partial staging (git add -p)?
- lint-staged operates on the full file, not just the staged hunks.
- This means unstaged changes in the same file are also processed.
- For critical cases, commit the full file instead of partial staging.
How do I add commit message linting with commitlint?
npm install --save-dev @commitlint/cli @commitlint/config-conventional
echo "npx commitlint --edit \$1" > .husky/commit-msgThis enforces conventional commit messages like feat:, fix:, docs:.
Does lint-staged re-stage files after fixing them?
- Yes. Fixed files are automatically re-staged so the commit includes the fixes.
- You do not need to manually
git addfiles after ESLint or Prettier auto-fix.
Why does ESLint warn about ignored files when run through lint-staged?
- lint-staged passes explicit file paths to ESLint.
- Files matching your ESLint ignore patterns trigger warnings when passed explicitly.
- Add
--no-warn-ignoredto the ESLint command in your lint-staged config.
What TypeScript issues can ESLint auto-fix in the pre-commit hook?
- Remove unused imports.
- Add the
typekeyword to type-only imports. - Fix
consistent-type-importsviolations. - Reorder imports according to
import/orderrules.
Related
- ESLint Setup for Next.js — ESLint configuration for the lint step
- Prettier Setup — Prettier configuration for the format step
- ESLint + Prettier Integration — ensure no conflicts between the two
- Linting in CI/CD — CI safety net for hooks
- EditorConfig & VS Code Settings — editor-level consistency