# Husky: The Complete Guide to Git Hooks in JavaScript Projects

_Researched on 4 October 2026 against husky 9.1.7, the latest release at the time of writing. Commands and behavior are taken from the official docs and release notes listed in the Sources section at the end._

Every team has shipped a commit that broke the build, failed the linter, or carried a message nobody could decode six months later. Code review and CI catch most of these, but they catch them late — after the push, after the context switch, after someone else has already pulled the mess. **Husky** moves those checks to the moment you type `git commit` or `git push`, so the feedback arrives in seconds on your own machine.

This guide covers what Husky is, how it works, how to set it up today, and how to avoid the pitfalls that trip up most teams.

* * *

## TL;DR

-   Husky is a small npm package that makes Git hooks easy to share across a team. MIT licensed, maintained by Typicode.
    
-   Setup in version 9 is two commands: `npm install --save-dev husky` and `npx husky init`.
    
-   A hook is just a file in the `.husky/` folder. The file `.husky/pre-commit` runs before every commit, and its contents are ordinary shell commands.
    
-   Pair it with **lint-staged** (run linters on staged files only) and **commitlint** (enforce commit message conventions).
    
-   Hooks can always be bypassed locally, so run the same checks in CI. Treat hooks as fast feedback, not enforcement.
    
-   If your hook files still start with the two-line `husky.sh` header from v8, remove it. It will fail in v10.
    

* * *

## 1\. Git hooks in 60 seconds

Git can run a script automatically at defined points in its workflow. These scripts are called **hooks**. The ones you will use most often:

| Hook | When it runs | Typical use |
| --- | --- | --- |
| pre-commit | Before a commit is created. Non-zero exit aborts. | Lint, format, run fast tests |
| commit-msg | After you write a message, before the commit completes. | Enforce message conventions |
| pre-push | Before a push. Non-zero exit aborts. | Type-check, full tests |

The `pre-commit` and `commit-msg` hooks can be skipped with `--no-verify`.

By default Git looks for hooks in `.git/hooks`. That directory is never committed, so hooks living there cannot be shared — every developer would have to copy scripts by hand. That is the problem Husky solves.

## 2\. What Husky is

Husky is an npm package that stores your hooks inside the repository and wires them into Git automatically when a teammate installs dependencies.

-   **License:** MIT
    
-   **Latest version:** 9.1.7 (November 2024)
    
-   **Size:** ~2 kB gzipped, zero dependencies
    
-   **Adoption:** ~35.8 million weekly downloads, used in over 1.5 million GitHub projects including Next.js, webpack, Angular, VS Code, Zod and Rollup
    

## 3\. A short history, and why old tutorials contradict each other

Husky has changed its configuration style several times. If a tutorial doesn't match what you see, check which major version it targets.

| Era | How hooks were defined |
| --- | --- |
| 0.x–4.x | JavaScript config in package.json or .huskyrc |
| 5.x–6.x | Switched to files in .husky/ |
| 7.x–8.x | husky install + husky add; hooks had a two-line header sourcing husky.sh |
| 9.x | husky init; hooks are plain shell files |

**Why the shift from JS config to files?** Before v5, Husky installed every possible Git hook into `.git/hooks`, each launching a Node script to check your config. That started Node on every Git operation even when nothing was defined. The fix came from Git 2.9's `core.hooksPath`, which lets Git read hooks from a committed folder. No JavaScript middleman, one source of truth.

**What changed in v9** (January 2024): `husky init` replaced a three-step setup with one command. Adding a hook became "create a file." `husky install` was removed. Since v9.1, locally installed tools can be called directly in hooks without `npx`. The old shebang and `husky.sh` lines were deprecated — **hooks containing them will fail in v10**.

## 4\. Quick start with Husky 9

```bash
npm install --save-dev husky
npx husky init
```

`init` creates a `pre-commit` script in `.husky/` and adds `"prepare": "husky"` to `package.json`. Try it:

```bash
git commit -m "Keep calm and commit"
# your test script runs before the commit is created
```

Commit the `.husky/` folder and `package.json` so teammates get the same hooks.

## 5\. How Husky 9 works under the hood

1.  `prepare` **runs after install.** npm runs `prepare` after `npm install`, triggering `husky`, which sets things up.
    
2.  **Husky sets** `core.hooksPath` to `.husky/_`, so Git reads hooks from there instead of `.git/hooks`.
    
3.  **Hooks run with** `sh`**.** Write POSIX-compatible shell unless your whole team can run Bash.
    

If you uninstall Husky, restore normal behavior with `git config --unset core.hooksPath`.

## 6\. Writing hooks

A hook is a file named exactly after the Git hook:

```bash
echo "npm test" > .husky/pre-commit
```

Multiple commands go on separate lines:

```bash
# .husky/pre-commit
npm run lint
npm test
```

On Husky 9.1+, locally installed tools can be called directly (`jest`, `eslint`) without `npx`.

Some hooks receive arguments from Git. For `commit-msg`, `$1` is the path to the file holding the message. The old `HUSKY_GIT_PARAMS` variable no longer exists.

**Debugging:** use `HUSKY=2 git commit -m "debug run"` for verbose output.

* * *

## 7\. Recipes: lint-staged, commitlint, pre-push

### lint-staged: run tasks only on staged files

Running a linter across a whole project on every commit is slow. **lint-staged** passes only staged files to your tools.

```bash
npm install --save-dev lint-staged
```

```bash
# .husky/pre-commit
npx lint-staged
```

Configure in `package.json`:

```json
{
  "lint-staged": {
    "*.{js,jsx,ts,tsx}": ["eslint --fix", "prettier --write"],
    "*.{json,md,css}": "prettier --write"
  }
}
```

**Watch for concurrent tasks.** Overlapping globs that both edit files can race. Use negation patterns:

```json
{
  "lint-staged": {
    "!(*.ts)": "prettier --write",
    "*.ts": ["eslint --fix", "prettier --write"]
  }
}
```

**Type-checking caveat.** lint-staged appends filenames to commands, which makes `tsc` ignore your `tsconfig.json`. Use a function config:

```javascript
// lint-staged.config.mjs
export default {
  '*.{ts,tsx}': [() => 'tsc --noEmit', 'prettier --write'],
}
```

### commitlint: enforce commit message conventions

If your team uses Conventional Commits (`feat: ...`, `fix: ...`):

```bash
npm install --save-dev @commitlint/cli @commitlint/config-conventional
echo "module.exports = { extends: ['@commitlint/config-conventional'] }" > commitlint.config.js
```

```bash
echo "npx --no -- commitlint --edit \$1" > .husky/commit-msg
```

### A typical complete setup

```text
.husky/
  pre-commit    ->  npx lint-staged
  commit-msg    ->  npx --no -- commitlint --edit $1
  pre-push      ->  npm run typecheck && npm test
```

* * *

## 8\. Skipping and disabling hooks

**Single command:** `git commit -m "WIP" -n` (or `--no-verify`)

**For commands without** `--no-verify`**:** `HUSKY=0 git rebase main`

**Globally on your machine:** add `export HUSKY=0` to `~/.config/husky/init.sh`

* * *

## 9\. CI, Docker and production installs

**Disable in CI.** In GitHub Actions: `env: HUSKY: 0`

**Handle missing dev dependency.** In production installs where Husky isn't installed, prevent `prepare` from failing:

```json
{
  "scripts": {
    "prepare": "husky || true"
  }
}
```

**Run the same checks in CI.** Hooks are a local convenience. Anyone can skip them, so your pipeline must run lint, tests and commitlint independently.

* * *

## 10\. Monorepos and projects not at the Git root

Given a layout where `package.json` is in a subfolder:

```json
{
  "scripts": {
    "prepare": "cd .. && husky frontend/.husky"
  }
}
```

```bash
# frontend/.husky/pre-commit
cd frontend
npm test
```

* * *

## 11\. Package manager notes

| Manager | Install | Init |
| --- | --- | --- |
| npm | npm install --save-dev husky | npx husky init |
| pnpm | pnpm add --save-dev husky | pnpm exec husky init |
| Yarn | yarn add --dev husky | Manual: use postinstall instead of prepare |
| Bun | bun add --dev husky | bunx husky init |

Yarn doesn't support `prepare` the same way. Use `postinstall: "husky"` instead. If your package is published, add `pinst` to disable hooks in `prepack`/`postpack`.

* * *

## 12\. Node version managers and Git GUIs

If Git runs from a GUI and Node comes from nvm/fnm/Volta/etc., hooks can fail with `command not found` because the GUI never sources your shell profile.

Fix by adding version manager initialization to `~/.config/husky/init.sh`:

```bash
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
```

* * *

## 13\. Troubleshooting

| Symptom | Fix |
| --- | --- |
| Hooks don't run | Filename must be exactly pre-commit (not precommit or pre-commit.sh). Check git config core.hooksPath. Confirm Git ≥ 2.9. |
| Hooks not installed after clone | prepare didn't run. Check --ignore-scripts or missing prepare in package.json. |
| command not found in GUI | PATH/version manager issue. See section 12. |
| .git/hooks stopped working after uninstall | Run git config --unset core.hooksPath. |
| prepare fails in production | Use `husky |
| Deprecation warning about husky.sh | Delete the two header lines from hook files. |

* * *

## 14\. Migrating from v8 to v9

Version 9 is backward compatible with v8. Three edits:

1.  In `package.json`, change `"prepare": "husky install"` to `"prepare": "husky"`.
    
2.  In each hook file, delete the shebang line and the `husky.sh` sourcing line. Leave only your commands.
    
3.  Move any `~/.huskyrc` code to `~/.config/husky/init.sh`, and replace `HUSKY_DEBUG=1` with `HUSKY=2`.
    

Do this before v10 drops — hooks with the old header lines will fail.

* * *

## 15\. Husky vs the alternatives

| Tool | Approach | Best for |
| --- | --- | --- |
| Husky | Shell files in .husky/, wired via core.hooksPath | JS/TS projects wanting a tiny, native-feeling tool |
| lefthook | Go binary, YAML config, built-in parallelism | Polyglot repos needing file filtering and parallel tasks |
| simple-git-hooks | Zero-dependency, configured in package.json | Small projects preferring config over files |
| Raw core.hooksPath | Point Git at a committed folder yourself | Teams that want no tooling at all |

* * *

## 16\. Criticisms and trade-offs

-   **The v5 transition was rough.** Moving from JS config to files was breaking, and the brief non-MIT licensing in v5 pushed some teams to alternatives. v6 returned to MIT.
    
-   **Hooks are advisory.** Anyone can skip with `--no-verify` or `HUSKY=0`. CI must repeat your checks.
    
-   **It rewrites a Git setting.** `core.hooksPath` means Husky doesn't coexist with other tools that expect `.git/hooks`. Pick one hook manager per repo.
    

* * *

## 17\. Best practices checklist

-   Keep `pre-commit` fast (a few seconds). Use lint-staged.
    
-   Put slow checks (type-check, full tests) in `pre-push` or CI.
    
-   Commit the `.husky/` folder and the `prepare` script.
    
-   Repeat every hook check in CI. Hooks are not enforcement.
    
-   Write hooks in POSIX shell.
    
-   Use `husky || true` so production installs don't break.
    
-   Set `HUSKY: 0` in CI jobs.
    
-   Remove deprecated `husky.sh` header lines before v10.
    
-   Document how to bypass hooks responsibly so people don't delete them.
    

* * *

## 18\. FAQ

**Do I need lint-staged?** No. Husky can run any command. lint-staged is useful when you want tools to run only on staged files, keeping hooks fast on large codebases.

**Does it work with pnpm, Yarn and Bun?** Yes. See section 11 for per-manager setup.

**Does it work in Git GUI clients?** Yes, with one caveat: add your version manager's init to `~/.config/husky/init.sh` (section 12).

**How do I uninstall?**`npm uninstall husky`, remove `prepare` and `.husky/`, then `git config --unset core.hooksPath`.

* * *

## Sources

Research done on 4 October 2026. Statistics will have changed since.

**Husky**

-   Official docs: [Introduction](https://typicode.github.io/husky/), [Get started](https://typicode.github.io/husky/get-started.html), [How To](https://typicode.github.io/husky/how-to.html), [Troubleshoot](https://typicode.github.io/husky/troubleshoot.html), [Migrate from v4](https://typicode.github.io/husky/migrate-from-v4.html)
    
-   Release notes: [v9.0.1](https://github.com/typicode/husky/releases/tag/v9.0.1), [v9.1.7](https://github.com/typicode/husky/releases/tag/v9.1.7), [v6.0.0](https://github.com/typicode/husky/releases/tag/v6.0.0)
    
-   Author's blog post: [Why husky has dropped conventional JS config](https://blog.typicode.com/husky-git-hooks-javascript-config/)
    
-   npm: [npmjs.com/package/husky](https://www.npmjs.com/package/husky)
    

**Companion tools**

-   lint-staged: [github.com/lint-staged/lint-staged](https://github.com/lint-staged/lint-staged)
    
-   commitlint: [Guide: Local setup](https://commitlint.js.org/guides/local-setup.html)
    

**Git**

-   [githooks documentation](https://git-scm.com/docs/githooks)
    

**Alternatives**

-   lefthook: [Evil Martians introduction](https://evilmartians.com/chronicles/lefthook-knock-your-teams-code-back-into-shape)
    
-   simple-git-hooks: [github.com/toplenboren/simple-git-hooks](https://github.com/toplenboren/simple-git-hooks)

---

*Published via [ZyVOP](https://zyvop.com/husky-the-complete-guide-to-git-hooks-in-javascript-projects-4c7ip?utm_source=hashnode&utm_medium=crosspost&utm_campaign=syndication) — Write once in Markdown, auto-backup to GitHub, and syndicate to Dev.to, Medium & Hashnode in 1 click.*
