Having ESLint and Prettier configured only helps if they actually run — and relying on every developer to remember is how unformatted, lint-failing code sneaks into the repo. Git hooks automate this: scripts Git runs at points in its lifecycle, most usefully pre-commit (before a commit is created) and commit-msg (to validate the message). Husky makes those hooks easy to share across a team, and lint-staged runs your checks only on the staged files so commits stay fast. This page covers Git hooks, Husky, lint-staged, commit-message linting, and why hooks complement — not replace — CI.
What Git hooks are
Hook
Fires
Common use
pre-commit
Before the commit is created
Lint & format staged files; abort on failure
commit-msg
After the message is written
Validate message format (Conventional Commits)
pre-push
Before pushing to a remote
Run tests / type-check before sharing code
Git hooks are scripts Git runs at lifecycle points — a non-zero exit aborts the action, so they can gate commits
**Git hooks** are scripts Git executes automatically at specific moments in its workflow. The key property: if a hook **exits non-zero, Git aborts** the operation — so a `pre-commit` hook that runs your linter and exits with an error *prevents the commit from being created* until the problem is fixed. The most useful hooks for a Node project are **`pre-commit`** (run lint/format on what you're about to commit), **`commit-msg`** (enforce a message convention), and **`pre-push`** (run the heavier test/type-check before code leaves your machine). Natively, hooks live in `.git/hooks/` — but that directory **isn't committed**, so hooks there aren't shared with the team. That's the gap Husky fills.
Husky — shareable hooks
Bash
npm install --save-dev husky
npx husky init # creates .husky/ and wires up the prepare script
# Add a pre-commit hook (a committed file in .husky/):
echo "npx lint-staged" > .husky/pre-commit
package.json
JSON
{
"scripts": {
"prepare": "husky" // runs on 'npm install' → installs the hooks for everyone
}
}
Husky stores hooks in a committed `.husky/` folder and installs them on `npm install` — so the whole team gets them
**Husky** solves the "hooks aren't shared" problem. It keeps your hook scripts in a **`.husky/` directory that *is* committed** to the repo, and registers them with Git via a `prepare` npm script that runs automatically on `npm install`. So when any teammate clones and installs, the hooks are set up for them — no manual steps, everyone runs the same checks. `npx husky init` scaffolds the folder and the `prepare` script; you then add hooks as small files (`.husky/pre-commit`) containing the command to run. This turns ad-hoc, easy-to-skip local checks into a **consistent, version-controlled** part of the project that every contributor gets by default.
lint-staged — only check what changed
Bash
npm install --save-dev lint-staged
package.json
JSON
{
"lint-staged": {
"*.{js,ts}": ["eslint --fix", "prettier --write"], // lint + format JS/TS
"*.{json,md,css}": ["prettier --write"] // just format the rest
}
}
Run checks on STAGED files only, not the whole repo — linting everything on each commit is slow and flags unrelated code
Running ESLint and Prettier over the **entire repository** on every commit is slow and, worse, surfaces problems in files you didn't touch — blocking your commit on someone else's pre-existing issues. **lint-staged** fixes this by running your tools **only on the files Git has staged** for this commit. You map file globs to commands (`"*.{js,ts}": ["eslint --fix", "prettier --write"]`), and lint-staged runs them against just the staged subset, then **re-stages** the files the tools modified so the formatted/fixed version is what gets committed. The result is a `pre-commit` hook that's *fast* (work scales with your change, not the codebase) and *focused* (only your changes are gated). The standard combo is **Husky's `pre-commit` → `npx lint-staged` → ESLint + Prettier on staged files**, auto-fixing and re-staging in one step.
Commit-message linting
Bash
npm install --save-dev @commitlint/cli @commitlint/config-conventional
echo "export default { extends: ['@commitlint/config-conventional'] }" > commitlint.config.js
# Wire it into the commit-msg hook:
echo 'npx --no-install commitlint --edit "$1"' > .husky/commit-msg
$ git commit -m "fixed stuff"
⧗ input: fixed stuff
✖ subject may not be empty / type may not be empty [type-empty]
✖ found 1 problem
# rejected — use e.g. "fix: handle null user in auth middleware"
A `commit-msg` hook with commitlint enforces Conventional Commits — consistent history that can drive changelogs and versioning
Beyond linting code, a **`commit-msg`** hook can enforce a consistent **commit-message format**. The popular convention is **[Conventional Commits](https://www.conventionalcommits.org/)** (`feat:`, `fix:`, `docs:`, `chore:`, etc.), and **commitlint** validates each message against it, rejecting commits that don't conform. The payoff isn't pedantry: a structured history is **machine-readable**, so tools can auto-generate changelogs, determine the next semantic version (feat → minor, fix → patch, breaking → major), and make `git log` genuinely scannable. Pair it with a tool like `commitizen` for an interactive prompt that helps developers write conformant messages. It's optional, but on a team it brings the same consistency to your history that Prettier brings to your code.
Hooks complement CI — they don't replace it
Hooks can be bypassed with `--no-verify` and run only locally — keep the same checks in CI as the real enforcement
Git hooks are a fast **first line of defense**, not the last word. They're trivially **bypassable** — `git commit --no-verify` skips them entirely — they only run on machines where Husky was installed, and a misconfigured local environment can silently skip them. So a hook catching a lint error is a *convenience* (fix it now, on your machine, in seconds), but it is **not enforcement**. The authoritative gate must be **[CI](/nodejs/ci-cd)**: run `eslint .`, `prettier --check .`, `tsc --noEmit`, and your tests on every push/PR in a clean environment that *cannot* be bypassed, and block merges on failure. Think of it as layers — editor feedback (instant) → pre-commit hooks (fast, local, skippable) → CI (authoritative, unskippable). Hooks make CI rarely *catch* anything because problems are fixed earlier; CI guarantees nothing slips through regardless.
Next
Round out your toolkit with other handy utilities: [Useful Developer Tools](/nodejs/debugging-tools).