Git hooks run scripts around local Git events (pre-commit, commit-msg, pre-push, and others). They are a good place for fast checks - lint, format, and lightweight validation - before bad changes leave your machine. Hooks are not a substitute for CI; treat them as an early filter.
Husky wires those hooks through Git's core.hooksPath. Hook files live in .husky/ as plain shell scripts, and a prepare script installs them after npm install so every clone gets the same setup.
This post covers Husky v9 setup, common hooks, lint-staged, skipping hooks in CI, pitfalls, and a runnable demo that blocks messy commits.
Prerequisites
- Node.js version 26
- A Git repository (
git initif you are starting fresh) npm i -D husky- Optional but recommended:
npm i -D lint-stagedplus ESLint and Prettier
Setup
Install Husky and initialize it:
npm i -D husky
npx husky init
husky init creates .husky/pre-commit and adds a prepare script to package.json:
{
"scripts": {
"prepare": "husky"
}
}
prepare runs after local npm install, so teammates get hooks without a separate setup step. Replace the default pre-commit body with the checks you want.
Mental model
| Piece | Role |
|---|---|
prepare / husky |
Points Git at .husky/ via core.hooksPath
|
.husky/<hook> |
Shell script Git runs for that event |
lint-staged |
Runs tools only on staged files (typical pre-commit target) |
| CI | Still runs full lint/test; hooks are local only |
Husky does not invent a new hook system - it configures native Git hooks and keeps the scripts in the repo.
Pre-commit with lint-staged
Running eslint . or prettier --write on the whole tree from pre-commit gets slow. lint-staged limits work to staged files.
npm i -D lint-staged eslint prettier
// package.json
{
"lint-staged": {
"*.{js,ts}": ["eslint --fix", "prettier --write"]
}
}
# .husky/pre-commit
npx lint-staged
On git commit, Git runs .husky/pre-commit → lint-staged → ESLint/Prettier on matching staged paths. Auto-fixed files are restaged. If a check exits non-zero, the commit is aborted.
Keep pre-commit fast and file-local. Put slow or whole-repo work in pre-push or CI.
Other useful hooks
commit-msg - validate the message file Git passes as $1:
# .husky/commit-msg
message=$(cat "$1")
if echo "$message" | grep -qE '^(WIP|wip)($|[^a-zA-Z])'; then
echo "WIP commits are blocked by the commit-msg hook"
exit 1
fi
pre-push - run a quicker subset of CI before a push:
# .husky/pre-push
npm test
Create a hook by adding an executable script under .husky/ with the Git hook name. Prefer POSIX shell for Windows compatibility.
Skipping hooks
Skip once with Git's no-verify flag:
git commit -m "emergency fix" -n
Disable Husky for a command or environment:
HUSKY=0 git commit -m "skip hooks"
Set HUSKY=0 (or HUSKY: 0 in CI env) on CI/Docker so installs do not try to configure local hooks. If production installs omit devDependencies, use a safe prepare script so missing husky does not fail the install:
{
"scripts": {
"prepare": "husky || true"
}
}
Pitfalls
- Hooks are local - they do not run on the remote; CI still matters.
-
--no-verify/HUSKY=0- anyone can bypass hooks; do not rely on them for security. -
GUI + Node version managers - GUI clients often lack the shell
PATHwherenvm/fnmput Node. Source your version manager from~/.config/husky/init.sh. -
Nested package vs Git root - Husky will not install into a parent
../by default. In a monorepo, install Husky at the repo root (or adjustprepareas in the Husky how-to). -
Slow pre-commit - full test suites belong in
pre-pushor CI; keep commit hooks on staged files.
When to use what
| Approach | Good for |
|---|---|
| Husky + lint-staged | Fast lint/format on staged files before commit |
Husky commit-msg |
Lightweight message conventions locally |
Husky pre-push |
Short tests before sharing a branch |
| CI only | Full lint, build, and test on every change |
Pair Husky with ESLint and Prettier for the usual Node.js quality gate on commit.
Need help with your project?
Get personalized advice on your architecture, code, or career in a 45-minute 1-on-1 consultation.
Top comments (0)