husky pre commit
git hooks
code quality
lint-staged
npm scripts

Husky Pre Commit: Your Guide to Flawless Code Commits

Husky Pre Commit: Your Guide to Flawless Code Commits

You ship a feature. The logic is solid. The tests for the new behavior pass locally. Then the pull request opens, and the first comments have nothing to do with architecture, product intent, or edge cases. Someone points out formatting drift, a lint warning, and a file that should never have been committed in that state.

That kind of review cycle is expensive because it burns attention on work a machine should handle. A good husky pre commit setup fixes that at the source. Instead of asking every developer to remember the same checklist before every commit, Git runs the checks automatically and blocks bad commits before they land in history.

The difference isn't philosophical. It's operational. Teams move faster when reviewers focus on logic, not whitespace, and when local tooling catches problems before CI turns them into a slower feedback loop.

Stop Wasting Time on Code Style Reviews

A lot of teams still treat formatting and linting as review problems. They aren't. They're workflow problems.

A stressed programmer sitting at a desk with his head in his hands, facing a code editor.

The usual pattern looks familiar. One developer forgets to run Prettier. Another commits code that passes in one editor but violates the repo's ESLint rules. A reviewer leaves comments. The author pushes a cleanup commit. Nobody learned anything, and the pull request got longer for no product reason.

Husky exists to stop that loop. It's used in over 1.5 million projects on GitHub and stays lightweight at 2 kB gzipped with no dependencies, which is why it fits cleanly into real development workflows instead of becoming another bloated layer of tooling (Husky documentation).

What changes when commits police themselves

Once a pre-commit hook is in place, the feedback arrives at the right moment. The developer sees the failure while the changed files are still fresh in working memory. That matters more than people admit. Fixing a formatting or lint issue five seconds after git commit fails is easy. Fixing it after review comments, context switching, and branch updates is friction.

A style guide still matters, because automation only works if the rules are clear. If your team hasn't documented conventions well, this reference on implementing a style guide is useful because it turns vague preferences into enforceable standards.

The review checklist should shrink

When hooks handle mechanical checks, review quality improves. Reviewers stop scanning for low-value cleanup and spend more time on naming, domain logic, test quality, and maintainability. That's a healthier division of labor.

If your current review process still mixes architecture feedback with formatting trivia, this code review checklist for engineering teams is a good companion to a Husky setup because it helps separate human judgment from automation.

Practical rule: If a reviewer can spot it instantly and a tool can fix it automatically, it shouldn't survive long enough to reach a pull request.

Why Pre-Commit Hooks Are a Developer's Best Friend

A pre-commit hook is just a script Git runs before it finalizes a commit. If that script exits with an error, the commit stops. That's the whole mechanism. The power comes from choosing what that script checks.

With the right setup, a pre-commit hook can catch up to 90% of common coding errors before they enter the main branch, including syntax mistakes, style violations, and failing tests (Git Tower on Husky hooks). That number isn't a promise that every codebase gets the same outcome. It's proof that local checks can eliminate a huge class of avoidable issues before they spread.

Local feedback beats delayed feedback

CI is still necessary. It validates the branch in a shared environment, runs broader suites, and protects the main branch. But CI is too late for basic hygiene. If a commit fails because of a missing import, inconsistent formatting, or a simple lint error, the developer should learn that before pushing.

That changes the shape of daily work in a few concrete ways:

  • Cleaner commit history keeps fixup noise out of the branch because developers correct issues before the commit exists.
  • Faster reviews let reviewers focus on implementation instead of editor differences.
  • Less CI waste means remote pipelines spend less time rerunning jobs for problems that could have been blocked locally.
  • More consistent onboarding helps new teammates inherit working safeguards instead of tribal knowledge.

It helps outside the engineering bubble too

Product managers and designers feel the impact even if they never touch Git hooks directly. Better local validation reduces the back-and-forth caused by avoidable failures and keeps changes moving predictably through the pipeline. That's closely tied to broader developer handoff best practices, where the handoff isn't just design-to-dev but also dev-to-review and dev-to-release.

There’s also a team health angle here. If your process depends on people remembering the same repetitive checks every day, someone will skip them under deadline pressure. Not because they're careless, but because humans optimize for momentum. Hooks exist to remove that decision.

Why this belongs in your Git workflow

Strong version control isn't only about branch naming and merge strategy. It's also about what quality bar code must meet before it earns a place in history. That's why pre-commit hooks fit naturally with broader version control best practices for growing teams.

Good hooks don't replace discipline. They encode it so the whole team benefits from it consistently.

The main objection is usually speed. Developers worry that every commit will feel heavier. That's a real concern, and it's exactly why the next decision matters so much: what you run in pre-commit, and how narrowly you scope it.

Your First Husky Pre-Commit Hook Setup

A production-ready Husky setup starts simple. Don't begin with five tools, three shell branches, and a hook chain nobody understands. Start with one reliable pre-commit hook, make sure the team can install it cleanly, then add tasks deliberately.

A hand-drawn illustration showing a person typing commands to install and initialize the husky tool on a keyboard.

The modern setup revolves around npx husky init. That command adds a prepare script to package.json and creates a .husky/pre-commit script. One mistake causes a lot of pain later: putting full-repo scans directly into that hook, which can increase hook times by 30-50% in large repositories (Tighten on Husky setup).

Install Husky the right way

Use your package manager's normal dev dependency flow.

npm install --save-dev husky

If you're on Yarn or pnpm, use the equivalent add command for dev dependencies. The important part isn't the package manager. It's that Husky lives in the project, not in one developer's global setup.

Then initialize it:

npx husky init

That creates the .husky/ directory and wires the project so hooks can be installed when dependencies are installed. This matters for teams. If the setup depends on a teammate remembering a one-off local command from the wiki, it will drift.

What you'll see after initialization

Your repo now includes a .husky/pre-commit file and your package.json includes a prepare script. Keep both under version control.

A basic package.json shape looks like this:

{
  "scripts": {
    "prepare": "husky"
  },
  "devDependencies": {
    "husky": "..."
  }
}

And a simple pre-commit file might look like this:

#!/usr/bin/env sh
. "$(dirname, "$0")/_/husky.sh"

npm test

That is enough to prove the wiring works. Make a small change, stage it, and try to commit. If npm test fails, Git should stop the commit.

Start with a small, trustworthy command

A lot of teams make the hook too ambitious on day one. They stuff in unit tests, integration tests, type checks, formatting, linting, code generation checks, and maybe a shell script copied from an old repo. Then commits feel slow and developers start looking for bypasses.

Use this progression instead:

  1. First commit gate Start with one command your team already trusts, often a fast test command or a lightweight lint check.

  2. Verify installation behavior Clone the repo fresh or ask a teammate to pull and install dependencies. The hook should exist without any manual patching.

  3. Commit the .husky/ directory If you don't commit the hook files, you don't have team enforcement. You have a local experiment.

  4. Add only fast checks Anything that makes developers wait too long belongs later in the workflow, usually pre-push or CI.

The first version of a hook should be boring. Boring is maintainable.

A practical first setup

If your project already has a fast test command, keep the first iteration straightforward.

#!/usr/bin/env sh
. "$(dirname, "$0")/_/husky.sh"

npm test

If your tests are heavy, use a linter first instead:

#!/usr/bin/env sh
. "$(dirname, "$0")/_/husky.sh"

npm run lint

The exact command depends on your repo. The more important judgment is this: pre-commit should validate what can fail quickly and predictably.

What not to put in the first hook

These are the choices that usually backfire:

  • Full-repo formatting commands
    Commands like formatting every file in the repository feel simple, but they create unnecessary delay and can modify unrelated files during a tiny commit.

  • Long-running test suites
    If your default test command takes long enough that developers hesitate before committing, the hook will become a target for --no-verify.

  • Environment-dependent scripts
    Hooks should run on a teammate's machine without special shell assumptions, hidden aliases, or manual path tweaks.

A minimal baseline for a JavaScript app

Here's a clean first pass that many teams can live with:

{
  "scripts": {
    "prepare": "husky",
    "lint": "eslint .",
    "test": "npm run test:unit"
  }
}
#!/usr/bin/env sh
. "$(dirname, "$0")/_/husky.sh"

npm run lint

That gets the enforcement layer in place. It doesn't try to solve every problem yet. It just proves that your Git workflow can block bad commits using shared, versioned hooks.

Why the prepare script matters

The prepare script is the part many mid-level devs gloss over, but it's one of the most practical details in the whole setup. It makes hook installation part of normal dependency installation. That's what turns Husky from personal tooling into team tooling.

Without that script, new developers clone the repo, install packages, assume everything is ready, and commit code with no hooks running. The team thinks it has guardrails, but it doesn't.

The handoff from basic to production-ready

Once the first hook runs reliably, the next priority isn't adding more commands blindly. It's narrowing scope so the hook stays fast. That's where teams either level up or undermine their own setup.

If you remember only one thing from the initial configuration, remember this: a husky pre commit hook is only useful if developers keep it enabled. Performance is not a nice-to-have. It's part of correctness because slow hooks invite bypass behavior.

Supercharge Your Hooks with Lint-Staged

Husky is the hook runner. lint-staged is what makes the hook practical at team scale.

Without lint-staged, teams often run ESLint, Prettier, or tests across the entire codebase on every commit. That sounds safe until the repo grows. Then a tiny change to one file triggers work across hundreds or thousands of files, and the pre-commit hook becomes a speed bump instead of a guardrail.

A hand-drawn illustration showing a lightning bolt striking a stack of papers labeled as staged files.

In a repository with over 10,000 lines of code, a pre-commit hook using husky with lint-staged averages 1.2 seconds, compared to over 5 seconds for a full scan (Built In on lint-staged with Husky). That's why I treat lint-staged as essential for most JavaScript and TypeScript teams. It keeps the quality bar high without turning every commit into a wait.

Why staged files are the only sane scope

A pre-commit hook should care about what you're committing now. Not every unresolved lint issue in the repo. Not legacy formatting drift in unrelated folders. Not stale files someone else touched last month.

That principle matters for adoption as much as performance. Developers will tolerate strict hooks when the scope feels fair. They resist hooks that fail because of unrelated parts of the codebase.

If your hook blocks a commit for code the developer didn't touch, the problem is usually the hook, not the developer.

Install and wire lint-staged

Add the dependency:

npm install --save-dev lint-staged

Then create a configuration, either in package.json or in a dedicated config file. A typical package.json setup looks like this:

{
  "scripts": {
    "prepare": "husky",
    "precommit:check": "lint-staged"
  },
  "lint-staged": {
    "*.{js,jsx,ts,tsx}": [
      "eslint --fix",
      "prettier --write"
    ],
    "*.{json,md,css,scss,html,yml,yaml}": [
      "prettier --write"
    ]
  }
}

Then point your hook at lint-staged:

#!/usr/bin/env sh
. "$(dirname, "$0")/_/husky.sh"

npm run precommit:check

That structure is simple for a reason. It keeps logic in package configuration and keeps the shell hook thin.

Format before you lint

Order matters more than some guides admit. Put your formatter before the linter when that sequence matches your ruleset and tool behavior. A lot of noisy failures come from running tools in an order that creates false friction.

A stable pattern for frontend repos is:

  • Prettier first when formatting is authoritative
  • ESLint next to fix or validate what remains
  • Tests only if they're fast enough for local commit flow

If your lint command already integrates formatting concerns, adjust accordingly, but don't guess. Test the order on a messy staged file and see which sequence produces the least churn.

A configuration that scales better than a giant shell script

Keep file targeting in lint-staged, not in hand-written shell branching when you can avoid it.

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

That gives you:

  • Targeted execution on staged files only
  • Cleaner commits because auto-fixable issues are handled before the commit completes
  • Less shell complexity than trying to parse changed files manually in .husky/pre-commit

What doesn't work well

Some setups look impressive and perform badly.

One common mistake is putting eslint . or prettier --write . in pre-commit because it's easy to remember. That scales poorly. Another is mixing broad test suites into the same hook without separating what must happen on commit versus what can wait until push.

A better split is:

Hook Best use
pre-commit Fast formatting, linting, lightweight validation
pre-push Broader test suites, type checks, slower validation
CI Full pipeline, integration checks, environment-specific verification

That's the setup that respects developer flow while still enforcing standards.

Advanced Configurations and Real-World Scenarios

Most Husky tutorials assume a single-package app with current tooling and a tidy repo root. That's not where many teams live. Real projects have legacy configuration, nested apps, workspaces, and mixed stacks. That's where husky pre commit setups usually get fragile.

Data pulled from GitHub issues shows that about 15% of open bugs related to Husky in the last year concern pathing and execution problems in monorepos, and standard setup guides rarely deal with that directly (monorepo pathing discussion).

Monorepos fail when the hook pretends every package is the same

The common bad assumption is that one root hook can blindly run one root command and everything will behave. In a monorepo, that's often false. The frontend may use ESLint and Prettier. The backend may use a different toolchain. Mobile code may live in another package with its own scripts.

The wrong fix is to pile all of that into one unconditional script. That usually produces path resolution failures, workspace confusion, and unnecessary work on unaffected packages.

A better pattern is to route by changed file groups.

{
  "lint-staged": {
    "apps/frontend/**/*.{js,ts,tsx}": [
      "cd apps/frontend && prettier --write",
      "cd apps/frontend && eslint --fix"
    ],
    "apps/backend/**/*.{js,ts}": [
      "cd apps/backend && eslint --fix"
    ]
  }
}

That approach isn't pretty, but it is honest. It acknowledges that package boundaries exist and that hooks should respect them.

In monorepos, correctness starts with path awareness. If the hook doesn't know where a command should run, it will fail in ways that look random.

When this grows more complex, move repeated logic into package scripts so the hook calls stable entry points like npm run lint:staged inside each workspace.

Migrating from legacy Husky versions

Legacy Husky setups often store hook definitions in package.json under older configuration styles. Teams inherit these repos, upgrade dependencies, and suddenly the old config stops behaving the way people expect.

The safe migration path is straightforward:

  1. Identify the current hook behavior
    Before changing anything, read the existing commands and decide which ones still belong in pre-commit.

  2. Install the current Husky package
    Add Husky as a dev dependency in the repo that owns the Git root.

  3. Run npx husky init
    Let the modern structure create .husky/pre-commit and the prepare script.

  4. Move commands out of legacy config
    Copy the old pre-commit behavior into the new shell file, but don't preserve bad decisions just because they are old.

  5. Replace full scans with staged-file commands where possible
    Migration is the best moment to remove slow commands that trained the team to distrust hooks.

  6. Test on multiple machines
    One machine proving the hook works is not enough. Validate on at least one teammate's environment before calling the migration done.

This is also a good time to review broader release quality habits. A simple software testing checklist for production-minded teams helps decide which validations belong at commit time and which belong later in the pipeline.

A note on --no-verify

Developers should know the bypass exists. They should also know it's for exceptions, not normal workflow.

Use --no-verify when the hook itself is broken, when you're unblocking an emergency hotfix, or when local tooling is failing for a reason unrelated to the actual code change. Don't use it because the hook is slow every day. If bypass becomes common, fix the hook.

Windows and shell friction

Cross-platform reliability usually improves when your .husky/pre-commit file stays minimal and delegates work to package scripts. The more shell-specific logic you cram into the hook file, the more likely one environment will behave differently from another.

A good hook file is small. The heavy lifting belongs in commands the project already runs consistently.

Troubleshooting Common Husky Pre-Commit Errors

When Husky breaks, the error message often looks smaller than the underlying problem. Most failures come down to setup, path resolution, or the hook running the wrong command in the wrong place.

Read the failure in layers

Start with the basic question: did the hook run at all, or did the command inside it fail? Those are different problems.

If nothing happens on commit, the installation path is usually the issue. If Husky prints output and then exits, the hook is working and one of your tasks failed. That distinction saves time.

Common Husky Error Fixes

Symptom Likely Cause Solution
Nothing runs on commit Husky wasn't installed for the local clone, or the project setup didn't execute as expected Reinstall dependencies, confirm the repo includes the prepare script, and verify the .husky/ directory is committed
command not found inside pre-commit The hook calls a binary or script that isn't resolved correctly in that context Move the command behind an npm script or use npx where appropriate so the project resolves local binaries consistently
hook exited with code 1 The hook ran, but one command failed validation Read the lines above the exit code. The real cause is usually a lint error, test failure, or formatting problem
Monorepo command fails only in one package The hook runs from the repo root, but the command expects a package-specific working directory Change into the correct package directory or delegate to a package-level script
Developers keep bypassing with --no-verify The hook is too slow or fails on unrelated files Narrow the scope to staged files and move heavier checks out of pre-commit

Fast debugging habits

Use a short checklist instead of poking around randomly:

  • Confirm the hook file exists and is versioned in .husky/
  • Open the hook file and inspect the exact command being run
  • Run that command manually outside Git to see whether the problem is Husky or the task itself
  • Check staged files if you're using lint-staged, because an empty or unexpected staging set can affect behavior
  • Look for path assumptions in monorepos and nested apps

Most Husky errors aren't mysterious. They're usually one wrong directory, one missing script, or one command that was too broad for the hook stage.

Keep the hook thin

This is the most reliable troubleshooting advice I can give. If the hook file is tiny and just calls stable package scripts, debugging gets easier. If the hook contains branching shell logic, path gymnastics, and multiple inline commands, every failure takes longer to isolate.

Frequently Asked Questions About Husky Hooks

Should pre-commit hooks replace CI

No. Local hooks and CI solve different problems. Pre-commit catches fast, local issues before a commit is created. CI validates the branch in a shared environment and runs broader checks that are too slow or too environment-dependent for local commit flow.

Should I run tests in pre-commit

Only if they're fast and reliable enough that developers won't resent them. For many teams, formatting and linting belong in pre-commit, while heavier test suites fit better in pre-push or CI.

Can Husky do more than pre-commit

Yes. Teams often use hooks like pre-push for slower validation and commit-msg for commit message rules. The best use of Husky is not "run everything everywhere." It's assigning the right validation to the right point in the Git lifecycle.

Is --no-verify always bad

No. It's a legitimate escape hatch when the hook itself is broken or an urgent change can't wait. But if your team uses it regularly, treat that as a signal that the hook design needs work.

What's the simplest production-ready rule set

For most JavaScript and TypeScript repos, a good baseline is:

  • Pre-commit for staged-file formatting and linting
  • Pre-push for broader tests if needed
  • CI for the full safety net

That split keeps commits fast, keeps standards enforced, and avoids turning Husky into a local bottleneck.


If your team needs help standardizing delivery workflows across web or mobile projects, Nerdify can help you design practical engineering guardrails that developers will keep enabled.