Setting up Husky in a new project Jump to heading

Git hooks live in .git/hooks, which is not versioned, so a hook written by one developer never reaches anyone else. Husky solves that by keeping hooks in a versioned .husky/ directory and pointing Git at it with core.hooksPath whenever dependencies are installed. Version 9 reduced it to almost nothing: one dependency, one prepare script, and plain shell files. Setting it up in a new project takes minutes, but a few choices made at the start β€” which hooks, what they run, how they behave in CI β€” decide whether the team keeps them or learns to bypass them. This page walks through a clean setup, within local hook configuration with Husky.

When to use this approach Jump to heading

Step 1 β€” Install Husky and initialise it Jump to heading

husky init adds the dependency’s prepare script, creates .husky/ and writes a sample pre-commit hook.

npm install --save-dev husky
npx husky init
cat package.json | grep -A2 '"scripts"'      # "prepare": "husky"
ls .husky/                                   # pre-commit

The prepare script runs automatically after npm install, so every contributor who installs dependencies gets hooks configured without doing anything else.

# Verification: Git now looks for hooks in .husky/_
git config --get core.hooksPath              # .husky/_
How a contributor gets the hooksA contributor clones the repository and installs dependencies. npm runs the prepare script, which runs husky, which sets core.hooksPath so Git uses the versioned hooks in .husky. From then on every commit runs the team's hooks.git clonehooks not activenpm installruns preparehuskysets core.hooksPathgit commitruns .husky/pre-commitno manual step β€” the hooks arrive with the dependencies

Step 2 β€” Write the pre-commit hook Jump to heading

Hooks in Husky v9 are plain shell scripts with no boilerplate. Start with something fast that every commit benefits from: formatting and linting staged files through lint-staged.

npm install --save-dev lint-staged
printf 'npx lint-staged\n' > .husky/pre-commit
cat > .lintstagedrc.json <<'EOF'
{
  "*.{ts,tsx,js}": ["eslint --cache --fix", "prettier --write --cache"],
  "*.{json,md,yml}": "prettier --write --cache"
}
EOF

Keep this hook fast β€” a few seconds at most. Slow checks belong in pre-push or CI; the reasoning is in speeding up slow lint-staged runs.

Step 3 β€” Add a commit-msg hook Jump to heading

A commit-msg hook receives the path of the message file as its first argument. Use it to enforce your message convention, for example Conventional Commits with commitlint.

npm install --save-dev @commitlint/cli @commitlint/config-conventional
printf "export default { extends: ['@commitlint/config-conventional'] };\n" > commitlint.config.mjs
printf 'npx --no -- commitlint --edit "$1"\n' > .husky/commit-msg
# Verification: a bad message is rejected, a good one accepted
git commit --allow-empty -m "stuff" ; echo "exit: $?"            # rejected
git commit --allow-empty -m "chore: verify commit-msg hook"      # accepted
A starting set of hookspre-commit formats and lints staged files in seconds. commit-msg checks the message format. pre-push runs slower checks such as type checking once per push. Server-side CI repeats all of them, because client hooks can be skipped.pre-commitlint-stagedsecondscommit-msgcommitlintpre-pushtype check, testsCIsame checks,requiredhooks give fast feedback; CI is what actually enforces

Step 4 β€” Add pre-push for slower checks Jump to heading

Checks that take tens of seconds β€” a type check, a quick test subset β€” run once per push instead of on every commit.

printf 'npx tsc --noEmit\nnpm test -- --onlyChanged\n' > .husky/pre-push

The trade-offs of what to run before pushing are covered in running a fast test subset in a pre-push hook.

Step 5 β€” Make it behave in CI and production installs Jump to heading

The prepare script runs on every npm install, including in CI and in production images where there is no .git directory or no need for hooks. Husky v9 exits quietly when .git is missing, but installs that omit dev dependencies fail if prepare references a missing package. Guard it.

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

The fuller treatment, including HUSKY=0, is in skipping Husky in CI and production installs.

Step 6 β€” Document and verify the setup Jump to heading

Add a short section to the contributing guide: what each hook does, how long it should take, and what to do when it fails. Then verify on a fresh clone, which is what a new contributor experiences.

cd "$(mktemp -d)" && git clone "$REPO_URL" app && cd app
npm install
git config --get core.hooksPath            # .husky/_
echo "const x = 1" > scratch.ts && git add scratch.ts && git commit -m "test: hooks on fresh clone"
Hooks in .git/hooks against hooks managed by HuskyHooks written into .git/hooks are local to one clone, unversioned and invisible to reviewers. Husky hooks live in the repository, are reviewed like code, and are activated for every contributor by the install step..git/hooks.husky/ with Huskyversionednoyesreaches teammatesnoon npm installreviewed in PRsnoyesworks without Nodeyesnothe one cost is a Node dependency β€” which a JavaScript project already has

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Do I need the #!/bin/sh line or husky.sh sourcing from older versions? Jump to heading

No. Husky v9 hooks are plain scripts; the shebang and the sourcing line from v4–v8 are unnecessary and Husky warns about the old sourcing line. Files only need to exist in .husky/.

Can contributors opt out? Jump to heading

Individually, with git commit --no-verify for one commit, or HUSKY=0 in their environment to disable all hooks. That is acceptable because CI enforces the same checks; hooks are a convenience, not a security boundary.

Does Husky work with Git worktrees? Jump to heading

Yes. core.hooksPath is relative, so it resolves inside each worktree’s checkout of .husky/. Run npm install in each worktree, or share node_modules deliberately.