NodeJSGit Hooks (Husky & lint-staged)

Git Hooks (Husky & lint-staged)

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).