Husky: Git Hooks for JavaScript Projects
Husky is a Node.js tool that makes Git hooks easy to share across a team. Without Husky, Git hooks live in .git/hooks/ — a directory that is not committed to version control. Every developer would have to manually copy hook scripts after cloning. Husky solves this by storing hooks in a .husky/ directory that IS committed, and using an npm prepare script to install them automatically after every npm install.
Installation and setup (Husky v9+)
Install Husky
# Install as a dev dependency npm install --save-dev husky # Initialise Husky (creates .husky/ directory + adds prepare script) npx husky init # Output: # .husky/pre-commit created # package.json 'prepare' script added
package.json after husky init
{
"scripts": {
"prepare": "husky"
},
"devDependencies": {
"husky": "^9.0.0"
}
}Creating hooks
After npx husky init, a .husky/pre-commit file is created. Edit it to add your hook commands.
.husky/pre-commit — simple linting hook
#!/usr/bin/env sh npm run lint
Add more hooks manually
# Create a commit-msg hook echo "npx commitlint --edit $1" > .husky/commit-msg # Create a pre-push hook echo "npm test" > .husky/pre-push # Make them executable chmod +x .husky/commit-msg .husky/pre-push
The hook file runs as a shell script. $1 in commit-msg is the path to the temp file containing the commit message.
lint-staged: only lint what changed
Running ESLint or Prettier on the entire codebase on every commit is slow. lint-staged runs linters only on the files currently staged for commit — making hooks much faster.
Install lint-staged
npm install --save-dev lint-staged
package.json — lint-staged configuration
{
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{css,scss,json,yaml,md}": [
"prettier --write"
]
}
}.husky/pre-commit — run lint-staged
#!/usr/bin/env sh npx lint-staged
Now when you run git commit, lint-staged:
Identifies which files are staged (
git diff --name-only --cached).Runs ESLint --fix only on staged JS/TS files.
Runs Prettier --write only on staged files.
Re-stages the auto-fixed files.
If any check still fails after auto-fix, the commit is blocked.
commit-msg hook with commitlint
commitlint enforces conventional commit message format. Combined with Husky, every commit message is validated before it is saved.
Install commitlint
npm install --save-dev @commitlint/cli @commitlint/config-conventional
commitlint.config.js
module.exports = {
extends: ['@commitlint/config-conventional'],
}.husky/commit-msg
#!/usr/bin/env sh npx --no -- commitlint --edit "$1"
Examples of valid and invalid commit messages
# VALID: conventional commit format git commit -m "feat: add user authentication" git commit -m "fix: resolve login redirect loop" git commit -m "docs: update API documentation" git commit -m "chore: upgrade dependencies" # INVALID: blocked by commitlint git commit -m "added some stuff" # ✖ subject may not be empty [subject-empty] # ✖ type may not be empty [type-empty] git commit -m "Added login feature" # ✖ type may not be empty [type-empty]
Complete Husky + lint-staged + commitlint setup
package.json — full configuration
{
"scripts": {
"prepare": "husky",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"test": "vitest run"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{css,json,md,yaml}": [
"prettier --write"
]
},
"devDependencies": {
"husky": "^9.0.0",
"lint-staged": "^15.0.0",
"@commitlint/cli": "^19.0.0",
"@commitlint/config-conventional": "^19.0.0",
"eslint": "^8.0.0",
"prettier": "^3.0.0"
}
}.husky/pre-commit
#!/usr/bin/env sh npx lint-staged
.husky/commit-msg
#!/usr/bin/env sh npx --no -- commitlint --edit "$1"
.husky/pre-push
#!/usr/bin/env sh npm test
Husky v8 vs v9 changes
Feature | Husky v8 | Husky v9 |
|---|---|---|
Init command | npx husky-init && npm install | npx husky init |
Hook directory | .husky/ | .husky/ (same) |
prepare script | husky install | husky |
Hook format | #!/usr/bin/env sh + . .husky/_/husky.sh | Simple shell script, no source needed |
Bypass variable | HUSKY=0 git commit | HUSKY=0 git commit (same) |
CI behavior | Needs special handling | Auto-skips in CI if HUSKY=0 |
Bypassing hooks
Skip hooks when necessary
# Skip all hooks for one commit git commit --no-verify -m "chore: emergency fix" # Skip Husky specifically (useful in CI scripts) HUSKY=0 git commit -m "chore: automated commit"
CI environments
In CI, you typically do not want Husky installing hooks because:
The
npm installin CI should not take extra time installing Git hooks.CI runs on clean checkouts where hooks are irrelevant (CI has its own quality gates).
Some CI environments fail if
git configcalls fail due to permissions.
Skip Husky in CI
# Option A: set HUSKY=0 environment variable in your CI workflow # GitHub Actions: env: HUSKY: 0 # Option B: modify the prepare script to skip in CI # package.json: # "prepare": "node -e "if(process.env.CI) process.exit(0)" && husky"
Onboarding new team members
What happens when a new dev clones the repo
git clone https://github.com/org/repo.git cd repo npm install # postinstall runs: husky # Husky automatically installs the hooks into .git/hooks/ # Developer is now protected by all hooks — zero manual setup