Using Husky Professionally: Automating Git Workflows Without Slowing Your Team
Learn how to use Husky professionally to automate Git workflows, enforce code quality, validate commits, and improve developer experience with practical examples of pre-commit, pre-push, and commit-msg hooks.
Husky is often introduced as a simple way to run a linter before a commit. That is useful, but it barely scratches the surface.
In a professional codebase, Git hooks can become a reliable automation layer for enforcing engineering standards, preventing avoidable mistakes, and making repetitive checks happen automatically—without depending on every developer remembering a particular command.
The important part is knowing what belongs in a Git hook, what does not, and how to design the workflow so that it remains fast and maintainable.
This guide walks through a practical approach to using Husky in real-world JavaScript and TypeScript projects.
What Is Husky?
Git hooks are scripts that Git executes automatically when certain events occur.
For example:
pre-commitruns before a commit is created.commit-msgruns after the commit message is prepared.pre-pushruns before commits are pushed.post-mergeruns after a merge completes.
The problem is that managing these hooks directly inside a project can be inconvenient, especially when you want the hooks to be version-controlled and consistently installed across development environments.
Husky provides a convenient way to manage Git hooks from your project.
A typical project might look like this:
my-project/
├── .husky/
│ ├── pre-commit
│ ├── commit-msg
│ └── pre-push
├── src/
├── package.json
└── ...Once configured, Git automatically invokes the appropriate scripts.
Why Use Git Hooks Professionally?
The goal should not be:
"Let's put as many checks as possible into Husky."
That usually produces a frustrating development experience.
Instead, think about Git hooks as guardrails.
A good hook should answer one of these questions:
Can this mistake be detected cheaply before it leaves my machine?
or:
Is this repetitive task important enough that developers shouldn't have to remember it manually?
For example:
Developer
│
▼
git commit
│
▼
pre-commit
│
├── Format changed files
├── Run lint on changed files
└── Run fast validation
│
▼
Commit createdThe hook becomes an automated quality gate at the developer's workstation.
1. Start With pre-commit
The most common Husky hook is pre-commit.
A good pre-commit hook should generally contain fast checks.
For example:
npm run lint
npm run typecheckBut running an entire project's lint and type-checking process on every commit can become expensive.
Imagine a monorepo containing:
packages/
├── api/
├── frontend/
├── mobile/
├── shared/
└── tooling/If a developer changes one React component, running every validation across every package before every commit is unnecessary.
This is where tools such as lint-staged become useful.
2. Use lint-staged for Changed Files
Instead of checking the entire repository, lint-staged allows you to run commands against files that are actually staged for commit.
Install it:
npm install --save-dev lint-stagedThen configure it in package.json:
{
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{json,md,yml,yaml}": [
"prettier --write"
]
}
}Your Husky hook can then simply run:
npx lint-stagedNow consider a developer who modifies:
src/components/Button.tsx
src/utils/date.ts
README.mdInstead of processing the entire repository, the workflow focuses on those files.
That's a much better developer experience.
3. Formatting Should Be Automatic
Formatting is one of the best things to automate because developers generally don't need to make a conscious decision about it.
For example, with Prettier:
prettier --writeInstead of asking developers to remember:
npm run format
git add .
git committhe hook can handle formatting automatically.
A typical workflow becomes:
Write code
↓
git add .
↓
git commit
↓
Husky
↓
lint-staged
↓
Prettier + ESLint
↓
CommitThis eliminates an entire category of trivial formatting discussions during code review.
4. Don't Put Everything in pre-commit
This is one of the most important lessons when adopting Husky.
I've seen projects where pre-commit effectively becomes:
npm test
npm run build
npm run lint
npm run typecheck
npm run security-check
npm run generate
npm run integration-testsThat might look thorough.
In practice, it can make developers hate committing code.
If a commit takes two minutes to complete, developers will eventually start looking for ways around the process.
That's a sign that the hook is doing too much.
A better separation is:
| Hook | Typical responsibility |
|---|---|
pre-commit | Fast formatting and linting |
commit-msg | Commit message validation |
pre-push | More expensive local checks |
| CI | Full test/build/security pipeline |
The exact boundaries depend on the project, but the principle is consistent:
Fast feedback locally, comprehensive validation in CI.
5. Enforce Commit Message Conventions
Git history is an engineering asset.
A repository with commits such as:
fix
changes
update
final
new stuffbecomes difficult to understand later.
You can use Husky's commit-msg hook to enforce a convention.
For example:
feat: add password reset flow
fix: prevent duplicate payment requests
docs: update API documentation
refactor: simplify authentication middleware
test: add checkout integration testsA simple hook could execute:
npx commitlint --edit "$1"With a Conventional Commits configuration, invalid messages can be rejected automatically.
For example:
feat: add user authenticationpasses.
While:
added some stuffcan be rejected.
This becomes especially valuable when commit messages are consumed by:
- changelog generators
- release automation
- semantic versioning
- deployment systems
- engineering reports
The important point is that the hook enforces the convention rather than relying on documentation alone.
6. Use pre-push for More Expensive Checks
Some checks don't need to happen before every commit.
Suppose your test suite takes 30 seconds.
Running it every time someone commits a small documentation change is excessive.
Running it before pushing to a shared remote can be much more reasonable.
For example:
#!/bin/sh
npm run typecheck
npm testNow the workflow is:
git commit
│
├── formatting
└── lint
│
▼
commit
git push
│
├── typecheck
└── tests
│
▼
remoteThis creates a useful balance between speed and protection.
7. Use Different Checks for Different Projects
There is no universal Husky configuration.
For a small frontend application:
pre-commit
├── prettier
└── eslint
commit-msg
└── commitlint
pre-push
└── testsmight be enough.
For a Node.js backend:
pre-commit
├── prettier
└── eslint
commit-msg
└── commitlint
pre-push
├── typecheck
└── unit testsFor a large monorepo, you may want something more sophisticated:
pre-commit
└── affected packages only
pre-push
└── affected tests
CI
├── complete test suite
├── complete build
├── integration tests
└── deployment validationThe architecture should follow the project's size and complexity.
8. Don't Put Business Logic Inside Hooks
A Git hook should remain easy to understand.
Avoid turning .husky/pre-commit into a 100-line shell script containing application logic.
For example, this is difficult to maintain:
#!/bin/sh
if [ "$ENV" = "production" ]; then
# 40 lines of logic
fi
if [ -f "./some-file" ]; then
# another 30 lines
fi
# more logic...Instead, move meaningful logic into package scripts or dedicated tools.
For example:
{
"scripts": {
"validate": "npm run lint && npm run typecheck && npm test",
"format": "prettier --write ."
}
}Then your hook remains simple:
npm run validateThe benefit is that developers can also execute the same command manually.
That's an important property.
9. Prefer Package Scripts as the Interface
A useful pattern is:
Husky
↓
npm script
↓
actual toolingFor example:
{
"scripts": {
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"validate": "npm run lint && npm run typecheck && npm test"
}
}Then:
# .husky/pre-push
npm run validateNow the validation logic isn't tightly coupled to Husky.
If you later decide to move away from Husky, the commands still exist.
This is a subtle but important architectural decision:
Husky should orchestrate your development workflow, not own it.
10. Handle Dependencies Correctly
A common mistake is assuming every machine has the same environment.
For example:
eslint .works if ESLint is globally installed.
But that's not something you should depend on.
Your project should declare its development dependencies:
{
"devDependencies": {
"eslint": "...",
"husky": "...",
"lint-staged": "...",
"prettier": "..."
}
}Then invoke project-local tooling through your package manager.
For example:
npx lint-stagedor through an npm script:
{
"scripts": {
"lint:staged": "lint-staged"
}
}and:
npm run lint:stagedThis makes the workflow reproducible.
11. Make Hook Installation Part of Project Setup
A professional repository should not require every developer to manually configure Git hooks.
Husky can be initialized as part of the project's setup process.
For example, your project can have an installation script:
{
"scripts": {
"prepare": "husky"
}
}This means installing dependencies can also configure the repository's hooks.
The exact Husky setup can vary by version, so follow the current Husky documentation when initializing a new project.
The larger principle is more important:
Clone the repository, install dependencies, and the development environment should become usable with minimal manual configuration.
12. Make Hooks Fail Clearly
A failed hook should tell the developer what went wrong.
Bad:
Command failed with exit code 1Better:
ESLint found 3 errors.
Run:
npm run lint
to inspect the complete report.Even better, let the underlying tooling produce useful diagnostics.
For example:
src/auth/login.ts
24:7 error 'token' is assigned but never used @typescript-eslint/no-unused-varsA developer should immediately understand:
- What failed?
- Where did it fail?
- How can they reproduce it?
- What should they do next?
Good developer tooling reduces cognitive load.
13. Don't Treat Local Hooks as Your Security Boundary
This is another important distinction.
Husky runs on a developer's machine.
A developer can bypass hooks.
For example:
git commit --no-verifyTherefore:
Husky is not a security boundary.
It is a developer-experience and early-feedback mechanism.
If a check is mandatory, it should also exist in CI.
For example:
Developer machine
──────────────────
Husky
│
fast feedback / guardrails
│
▼
Git push
│
▼
CI pipeline
│
authoritative validation
│
▼
merge allowedThis distinction is critical.
You shouldn't depend on Husky to guarantee that production code passes tests.
14. Husky + CI Is the Better Architecture
Consider a pull request workflow.
Locally:
pre-commit
├── Prettier
└── ESLintBefore pushing:
pre-push
├── TypeScript
└── Unit testsOn GitHub Actions:
CI
├── install dependencies
├── lint
├── typecheck
├── unit tests
├── integration tests
├── build
└── security checksEach layer has a different purpose.
Husky
Fast feedback.
CI
Authoritative validation.
Production pipeline
Deployment safety.
Trying to make Husky perform the entire CI pipeline is usually the wrong abstraction.
15. A Practical Project Setup
Here's an example architecture I'd consider reasonable for a TypeScript project.
package.json
{
"scripts": {
"lint": "eslint .",
"format": "prettier --write .",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"validate": "npm run lint && npm run typecheck && npm test",
"prepare": "husky"
},
"devDependencies": {
"eslint": "...",
"husky": "...",
"lint-staged": "...",
"prettier": "...",
"typescript": "...",
"vitest": "..."
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{json,md,yml,yaml}": [
"prettier --write"
]
}
}.husky/pre-commit
npx lint-staged.husky/pre-push
npm run typecheck
npm test.husky/commit-msg
npx commitlint --edit "$1"The result is a relatively clean workflow:
Developer
│
▼
git commit
│
▼
pre-commit
│
┌──────┴──────┐
│ │
ESLint Prettier
│ │
└──────┬──────┘
│
▼
Commit
│
▼
git push
│
▼
pre-push
│
┌──────┴──────┐
│ │
Typecheck Tests
│ │
└──────┬──────┘
│
▼
CI16. A Mistake I Would Avoid: Overengineering Hooks
It is tempting to create an elaborate automation framework around Husky.
For example:
pre-commit
├── detect branch
├── detect package
├── detect environment
├── run custom Node script
├── inspect changed files
├── call another script
├── invoke another package manager
└── execute 15 checksAt that point, the hooks themselves have become infrastructure that needs maintenance.
Start simple.
A good first iteration might be:
pre-commit → lint-staged
commit-msg → commitlint
pre-push → tests + typecheck
CI → everything elseOnly introduce additional automation when there is a real problem to solve.
17. Use Hooks to Remove Human Memory
This is perhaps the best way to think about professional automation.
Suppose your team has a rule:
Every TypeScript file must be formatted with Prettier.
You can document that rule.
Or you can automate it.
Suppose the rule is:
Commit messages must follow Conventional Commits.
You can document it.
Or you can automate it.
Suppose the rule is:
Don't push code that fails the unit tests.
You can tell developers to remember to run the tests.
Or you can automate it.
The general principle is:
If something is deterministic, repetitive, and cheap to automate, don't rely exclusively on human memory.
That's where Git hooks provide real value.
18. When Should You NOT Use Husky?
Husky isn't automatically the right solution for every task.
Avoid putting something into a Git hook simply because it can be automated.
For example, a task that:
- takes several minutes
- requires network access
- depends on external services
- is flaky
- requires production credentials
- performs deployment
- modifies infrastructure
probably belongs somewhere else.
Use CI/CD, scheduled jobs, deployment pipelines, or dedicated tooling instead.
A useful rule is:
Fast + deterministic + developer-specific
↓
Husky
Comprehensive + authoritative + shared
↓
CI19. Think About Developer Experience
The technical correctness of a hook isn't enough.
You also need to consider how it feels to use.
A good developer workflow should look like:
git commit
↓
a few seconds
↓
successNot:
git commit
↓
download something
↓
run 800 tests
↓
wait 90 seconds
↓
random network failure
↓
commit blockedWhen hooks become slow or unreliable, developers will eventually bypass them.
That's not a developer problem.
That's a tooling design problem.
Measure your hooks.
If a hook consistently takes too long, ask:
- Can it operate only on changed files?
- Can the check move to
pre-push? - Can it move to CI?
- Can the underlying command be optimized?
- Does this check actually need to run locally?
20. The Bigger Picture
Husky isn't really about Git hooks.
It's about engineering systems that make the correct workflow the easiest workflow.
Instead of relying on:
Documentation
+
Developer memory
+
Manual commandsyou can build:
Repository configuration
+
Automation
+
CI validationThat creates a more predictable development environment.
A mature setup might eventually look like this:
Developer
│
├── save
│ └── editor formatting
│
├── commit
│ └── Husky + lint-staged
│
├── push
│ └── Husky + tests
│
└── pull request
└── CI
├── lint
├── typecheck
├── tests
├── build
├── security
└── deployment checksEach layer has a specific responsibility.
That's much more maintainable than trying to make Husky do everything.
Final Thoughts
Husky is easy to install. Using it well is the real engineering challenge.
The most effective setup isn't necessarily the one with the most hooks or the most checks. It's the one that provides useful feedback at the right moment without making development unnecessarily slow.
A practical approach is:
- Use
pre-commitfor fast checks. - Use
lint-stagedto avoid processing unrelated files. - Use
commit-msgfor commit conventions. - Use
pre-pushfor moderately expensive validation. - Keep the actual commands in package scripts.
- Keep complex logic out of shell hooks.
- Treat CI as the authoritative validation layer.
- Never rely on Husky as a security boundary.
- Optimize hooks for developer experience.
- Automate repetitive, deterministic rules instead of relying on human memory.
The goal isn't to make Git harder to use.
The goal is to make good engineering practices happen automatically.