| | |

ESLint 20 🛡️ Pre-commit Hooks — Husky & lint-staged integration

⏱️ Estimated read: 12 min | |

A pre-commit hook is the last line of defense before code enters the repository. It runs automatically when a developer runs git commit, and if it fails, the commit is aborted. The combination of Husky and lint-staged is the standard mechanism for this in JavaScript projects. Husky installs the Git hooks and manages their execution; lint-staged runs the configured commands only against the files that are actually staged, which is what makes the hook fast enough to run on every commit without the developer noticing a delay .

The division of labor is precise. Husky does not lint anything. It only ensures that a script runs when Git triggers the pre-commit event. lint-staged does not install hooks. It receives the staged file list from Git, filters it against glob patterns, and runs the configured commands on the matching files. The two tools are independent, and either can be used without the other, but the combination is what makes the workflow practical: Husky guarantees the hook runs, and lint-staged guarantees it runs quickly .

Key point: Husky installs the hook; lint-staged runs the commands on staged files. The hook is a one-liner: npx lint-staged. The configuration lives in package.json or a .lintstagedrc file, and the commands are the same commands the developer would run manually, with --fix or --write so that the corrections are applied automatically and re-staged.


Why the combination exists

The speed problem. A pre-commit hook that runs ESLint on the entire codebase is slow enough that developers disable it. The hook runs on every commit, and a full lint pass on a large project can take ten or twenty seconds, which is enough friction to make --no-verify a habit. lint-staged solves this by passing only the staged files to the command. The lint pass is proportional to the size of the commit, not the size of the repository, and a typical commit touches a handful of files .

The re-staging problem. A linter with --fix modifies files, but the modifications are not automatically included in the commit. The file on disk has the fix, but the index (the staged version) still has the original. lint-staged handles this: after a command modifies a file, it re-stages the modified version so that the fix is included in the commit. Without this behavior, the developer would commit the unfixed code and the fix would appear as an unstaged change .

The partial staging problem. A developer may stage only part of a file’s changes, leaving the rest unstaged. lint-staged stashes the unstaged changes before running the commands, so the linter only sees the staged content. After the commands run, the unstaged changes are restored. This prevents the linter from reporting problems on lines that are not part of the commit .

The trade-off. The hook is a convenience, not a security control. It can be bypassed with git commit --no-verify, and it only runs on machines where the developer has installed the dependencies. Any check that must be enforced—security linting, license checks, secret scanning—belongs in CI, where it cannot be skipped. The pre-commit hook catches the common cases early; CI catches everything else .


a. Installing Husky and lint-staged

The installation is two packages, one initialization command, and one file edit. The husky init command creates the .husky/ directory and adds a prepare script to package.json.

# Install both packages as dev dependencies.
npm install --save-dev husky lint-staged

# Initialize Husky.
# This creates the .husky/ directory and adds "prepare": "husky"
# to the scripts section of package.json.
npx husky init

The prepare script is what makes the hook installation persist across npm install. When a new developer clones the repository and runs npm install, the prepare script runs Husky’s installation, which sets core.hooksPath to .husky in the local Git configuration. Without the prepare script, the developer would need to run npx husky init manually after every clone .

// package.json — the prepare script added by husky init
{
  "scripts": {
    "prepare": "husky"
  }
}

The husky init command also creates a sample .husky/pre-commit file. The file is a POSIX shell script, and its content is the command that runs on commit. The initial content is usually npm test, which needs to be replaced with the lint-staged command.

# .husky/pre-commit — the hook file
# Replace the default content with the lint-staged command.
npx lint-staged

The hook file does not need a shebang line in recent Husky versions. The file is executed by Git, and Git uses the shell that is configured for the repository. The command npx lint-staged runs the locally installed lint-staged binary from node_modules .


b. Configuring lint-staged

The lint-staged configuration maps glob patterns to commands. The commands run sequentially for each matching file set, and the file paths are appended to the command automatically.

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

The order of the commands matters. eslint --fix runs first, applying the ESLint corrections. prettier --write runs second, formatting the result. If Prettier ran first, ESLint would reformat the code according to its own rules, and the formatting would be undone. The ESLint-first order ensures that the final output satisfies both tools, provided the ESLint configuration does not conflict with Prettier’s formatting (which is what eslint-config-prettier ensures) .

The --fix and --write flags are what make the hook useful. Without them, the command reports problems and the commit is blocked. With them, the command corrects what it can and the commit proceeds. The developer only sees a failure when there is a problem that cannot be fixed automatically, which is the case where manual intervention is actually required .

// lint-staged.config.js — an alternative configuration file
// The function form receives the file list and returns the command.
export default {
  '*.{js,jsx,ts,tsx}': (files) => {
    // The files are passed explicitly in the function form.
    // This is useful when the command needs to handle the list
    // differently, such as batching or filtering.
    return [`eslint --fix ${files.join(' ')}`, `prettier --write ${files.join(' ')}`];
  },
};

The configuration file can be a .lintstagedrc.json, .lintstagedrc.yaml, .lintstagedrc.js, or a lint-staged key in package.json. The package.json form is the simplest for a small configuration; the dedicated file is better when the configuration grows or when it needs comments .


Complete Example Session

// ============================================
// PART 1: THE INSTALLATION
// ============================================

// npm install --save-dev husky lint-staged
// npx husky init

// The init command creates .husky/pre-commit
// and adds "prepare": "husky" to package.json.

// ============================================
// PART 2: THE HOOK FILE
// ============================================

// .husky/pre-commit
// npx lint-staged

// The file is executable.
// The command runs the locally installed lint-staged.

// ============================================
// PART 3: THE CONFIGURATION
// ============================================

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

// The ESLint command runs first, then Prettier.
// The corrected files are re-staged automatically.

// ============================================
// PART 4: THE COMMIT FLOW
// ============================================

// The developer stages a file with a lint error:
// git add src/app.js

// The developer commits:
// git commit -m "feat: add app"

// The hook runs npx lint-staged.
// lint-staged passes src/app.js to eslint --fix.
// ESLint corrects the fixable problems.
// Prettier formats the corrected file.
// lint-staged re-stages the corrected file.
// The commit succeeds with the corrected code.

// ============================================
// PART 5: THE FAILURE CASE
// ============================================

// The developer stages a file with an unfixable error:
// git add src/app.js

// The developer commits:
// git commit -m "feat: add app"

// The hook runs npx lint-staged.
// eslint --fix reports the unfixable problem.
// The command exits with a non-zero code.
// lint-staged aborts the commit.
// The developer fixes the problem manually and retries.

// ============================================
// PART 6: THE BYPASS
// ============================================

// In an emergency, the hook can be bypassed:
// git commit -m "fix: urgent" --no-verify

// The --no-verify flag skips the hook entirely.
// This is not a recommended habit.
// The CI pipeline is the backup gate.

// ============================================
// PART 7: THE CI SAFEGUARD
// ============================================

// The pre-commit hook is a convenience.
// The CI pipeline must run the same checks without the hook.
// package.json
{
  "scripts": {
    "lint": "eslint .",
    "lint:ci": "eslint . --max-warnings 0"
  }
}

// The CI runs npm run lint:ci.
// The hook catches problems early; CI catches everything.

// ============================================
// PART 8: THE HUSKY DISABLE FOR CI
// ============================================

// In CI, Husky should not install hooks.
// The prepare script should be skipped or made non-fatal.
// package.json
{
  "scripts": {
    "prepare": "husky || true"
  }
}

// Or set HUSKY=0 in the CI environment.
// env:
//   HUSKY: 0

// ============================================
// PART 9: THE WORKTREE CAVEAT
// ============================================

// In a linked Git worktree, the hook may resolve the wrong path.
// The hook should use git rev-parse to find the active worktree.
// For most projects, this is not an issue.
// It becomes relevant in monorepos with multiple worktrees.

// ============================================
// PART 10: THE VERIFICATION
// ============================================

// [ ] husky and lint-staged are installed as devDependencies.
// [ ] .husky/pre-commit contains npx lint-staged.
// [ ] .husky/pre-commit is executable.
// [ ] package.json has "prepare": "husky".
// [ ] package.json has the lint-staged configuration.
// [ ] A test commit triggers the hook.
// [ ] A fixable problem is corrected and committed.
// [ ] An unfixable problem blocks the commit.

The ten parts covered the installation, the hook file, the configuration, the commit flow, the failure case, the bypass, the CI safeguard, the CI disable, the worktree caveat, and the verification checklist. The through-line is that the hook is a convenience, and the CI pipeline is the enforcement.


Quick Reference

Installation commands

CommandPurpose
npm install -D husky lint-stagedInstall both packages
npx husky initCreate .husky/ and add prepare script

The hook file

FileContent
.husky/pre-commitnpx lint-staged

The lint-staged configuration

GlobCommandOrder
*.{js,jsx,ts,tsx}eslint --fix then prettier --writeESLint first
*.{json,md,css}prettier --writeN/A

The CI safeguard

SettingPurpose
"prepare": "husky || true"Prevent CI failure if Husky is not installed
HUSKY: 0Disable Husky in CI

Best Practices

✅ Do This:

// Use the prepare script for persistence
"prepare": "husky"                                              // ✅

// Run ESLint before Prettier
"*.{js,ts}": ["eslint --fix", "prettier --write"]               // ✅

// Keep the CI check separate
"lint:ci": "eslint . --max-warnings 0"                          // ✅

❌ Don’t Do This:

// Run Prettier before ESLint
"*.{js,ts}": ["prettier --write", "eslint --fix"]               // ❌

// Rely on the hook as the only gate
// The hook can be bypassed with --no-verify.                    // ❌

// Run type-checking through lint-staged
"*.ts": "tsc --noEmit" // passes individual files, not the project // ❌

Common Pitfalls

PitfallWhy It HappensFix
Hook does not runHusky not initialized or prepare script missingRun npx husky init and verify prepare
Commit succeeds without fixes--fix or --write missingAdd the flags to the commands
Fixes are not committedlint-staged re-staging failsCheck for worktree issues or file permissions
CI fails on prepareHusky not installed in CIUse husky || true or HUSKY: 0
Type-check runs on individual filestsc --noEmit passes individual filesUse a separate script, not lint-staged

Real-World Examples

1. Basic hook

# .husky/pre-commit
npx lint-staged

2. Basic config

"lint-staged": { "*.js": "eslint --fix" }

3. ESLint and Prettier

"*.{js,ts}": ["eslint --fix", "prettier --write"]

4. Prettier only

"*.{json,md}": "prettier --write"

5. Prepare script

"prepare": "husky"

6. CI disable

env: { HUSKY: 0 }

7. CI safeguard

"prepare": "husky || true"

8. Bypass

git commit -m "msg" --no-verify

9. Config file

// .lintstagedrc.json
{ "*.js": "eslint --fix" }

10. Verify hook

ls -la .husky/pre-commit

Visual

The Commit Flow

┌──────────────────────────────────────────────────────────────┐
│  FROM git commit TO COMMIT COMPLETE                           │
│                                                              │
│  git commit -m "msg"                                         │
│         │                                                    │
│         ▼                                                    │
│  ┌─────────────────────┐                                    │
│  │ Git runs the        │  Husky installed the hook          │
│  │ pre-commit hook     │  at .husky/pre-commit              │
│  └──────────┬──────────┘                                    │
│             │                                                │
│             ▼                                                    │
│  ┌─────────────────────┐                                    │
│  │ npx lint-staged     │  The hook's only command           │
│  └──────────┬──────────┘                                    │
│             │                                                │
│             ▼                                                    │
│  ┌─────────────────────┐                                    │
│  │ Get staged files    │  git diff --staged --name-only    │
│  │ Match globs         │                                    │
│  │ Run commands        │  eslint --fix, prettier --write    │
│  └──────────┬──────────┘                                    │
│             │                                                │
│             ▼                                                    │
│  ┌─────────────────────┐                                    │
│  │ Re-stage fixed files│  The fixes are included            │
│  └──────────┬──────────┘                                    │
│             │                                                │
│             ▼                                                    │
│  ┌─────────────────────┐                                    │
│  │ All commands exit 0?│                                    │
│  └──────────┬──────────┘                                    │
│             │                                                │
│      ┌──────┴──────┐                                         │
│      │ Yes         │ No                                      │
│      ▼             ▼                                         │
│  ┌────────┐   ┌─────────────┐                               │
│  │ Commit │   │ Commit      │                               │
│  │ created│   │ aborted     │                               │
│  └────────┘   └─────────────┘                               │
└──────────────────────────────────────────────────────────────┘

The Two Tools

┌──────────────────────────────────────────────────────────────┐
│  HUSKY AND LINT-STAGED, SEPARATED                             │
│                                                              │
│  Husky                         lint-staged                  │
│  ┌─────────────────────┐       ┌─────────────────────┐     │
│  │ Installs the hook   │       │ Runs the commands   │     │
│  │ Manages execution   │       │ Filters staged files│     │
│  │ Sets core.hooksPath │       │ Re-stages fixes     │     │
│  │                     │       │ Stashes unstaged    │     │
│  └─────────────────────┘       └─────────────────────┘     │
│                                                              │
│  The hook is a one-liner: npx lint-staged                   │
│  The configuration lives in package.json or .lintstagedrc   │
└──────────────────────────────────────────────────────────────┘

The CI Gate

┌──────────────────────────────────────────────────────────────┐
│  HOOK IS CONVENIENCE, CI IS ENFORCEMENT                       │
│                                                              │
│  Local machine                  CI pipeline                  │
│  ┌─────────────────────┐       ┌─────────────────────┐     │
│  │ pre-commit hook     │       │ npm run lint:ci     │     │
│  │ Can be bypassed     │       │ Cannot be bypassed  │     │
│  │ Runs on staged files│       │ Runs on all files   │     │
│  │ Fast                │       │ Comprehensive       │     │
│  └─────────────────────┘       └─────────────────────┘     │
│                                                              │
│  The hook catches problems early.                           │
│  The CI pipeline catches everything the hook missed.        │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
Hook installerHusky
Staged-file runnerlint-staged
Hook commandnpx lint-staged
Configuration locationpackage.json or .lintstagedrc
Command ordereslint --fix then prettier --write
Re-stagingAutomatic for fixed files
Bypassgit commit --no-verify
CI safeguard"prepare": "husky || true" or HUSKY: 0

Key takeaways:

  • Husky installs the hook; lint-staged runs the commands. The npx husky init command creates the .husky/pre-commit file and adds the prepare script. The hook file’s content is npx lint-staged, which is the only command Husky needs to execute .
  • lint-staged only runs on staged files. It passes the staged file list to the configured commands, which keeps the hook fast enough to run on every commit. It also re-stages the fixed files so that the corrections are included in the commit .
  • The command order matters. eslint --fix runs first, then prettier --write. The ESLint corrections are formatted by Prettier, and the final output satisfies both tools, provided the ESLint configuration does not conflict with Prettier’s formatting .
  • The hook is a convenience, not a security control. It can be bypassed with --no-verify, and it only runs on machines where the dependencies are installed. The CI pipeline must run the same checks without the hook. The hook catches problems early; CI catches everything .
  • The prepare script makes the hook persistent. When a new developer clones the repository and runs npm install, the prepare script runs Husky’s installation, which sets core.hooksPath in the local Git configuration. Without it, the developer would need to run npx husky init manually .

Remember: The pre-commit hook is the last line of defense before code enters the repository, but it is a voluntary defense. Husky makes the hook run automatically, and lint-staged makes it fast by limiting the scope to the staged files. The combination catches the common cases—a missing semicolon, an unused variable, a formatting inconsistency—before they become part of the repository’s history. The rare cases that the hook misses are caught by the CI pipeline, which is the enforcement gate that cannot be bypassed. The hook is for the developer’s convenience; the CI is for the project’s integrity. The two are complementary, and neither replaces the other.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!