TechLead
Lesson 15 of 20
5 min read
DevTools & Productivity

Husky and lint-staged

Set up Git hooks with Husky and lint-staged to enforce code quality standards before every commit

Why Git Hooks?

Even with ESLint and Prettier configured, developers can forget to run them before committing. Git hooks solve this by automatically running checks at specific points in the Git workflow. Husky makes Git hooks easy to manage, and lint-staged ensures checks only run on files that are actually being committed, keeping hooks fast.

Common Git Hook Stages

  • pre-commit: Runs before a commit is created. Use for linting and formatting.
  • commit-msg: Runs after the commit message is entered. Use for enforcing commit message format.
  • pre-push: Runs before pushing to remote. Use for running tests or type checking.
  • post-merge: Runs after a merge completes. Use for running npm install if dependencies changed.

Setting Up Husky

# Install Husky
npm install -D husky

# Initialize Husky (creates .husky/ directory)
npx husky init

# This adds "prepare": "husky" to package.json scripts
# and creates .husky/pre-commit with a sample hook

Creating Hooks

# .husky/pre-commit - Run lint-staged before committing
npx lint-staged

# .husky/commit-msg - Validate commit message format
npx --no -- commitlint --edit $1

# .husky/pre-push - Run tests before pushing
npm run typecheck
npm run test -- --run

Setting Up lint-staged

lint-staged runs linters only on files that are staged for commit (in the Git staging area). This is critical for performance. Running ESLint on your entire codebase takes seconds or minutes; running it on just the 3 files you changed takes milliseconds.

# Install lint-staged
npm install -D lint-staged
// package.json - lint-staged configuration
{
  "lint-staged": {
    "*.{ts,tsx}": [
      "eslint --fix",
      "prettier --write"
    ],
    "*.{js,jsx}": [
      "eslint --fix",
      "prettier --write"
    ],
    "*.{json,md,yml,yaml}": [
      "prettier --write"
    ],
    "*.css": [
      "prettier --write"
    ]
  }
}

Alternatively, use a separate config file:

// lint-staged.config.js - Advanced configuration
export default {
  '*.{ts,tsx}': (filenames) => {
    const files = filenames.join(' ');
    return [
      `eslint --fix ${files}`,
      `prettier --write ${files}`,
      // Run typecheck on the whole project (not per-file)
      'tsc --noEmit',
    ];
  },
  '*.{json,md,yml}': ['prettier --write'],
  '*.css': ['prettier --write'],
};

Commitlint for Commit Messages

commitlint enforces a consistent commit message format. The most common convention is Conventional Commits, which structures messages as type(scope): description.

# Install commitlint
npm install -D @commitlint/cli @commitlint/config-conventional
// commitlint.config.ts
export default {
  extends: ['@commitlint/config-conventional'],
  rules: {
    'type-enum': [
      2,
      'always',
      [
        'feat',     // New feature
        'fix',      // Bug fix
        'docs',     // Documentation
        'style',    // Formatting (no code change)
        'refactor', // Restructuring code
        'perf',     // Performance improvement
        'test',     // Adding tests
        'build',    // Build system / dependencies
        'ci',       // CI/CD changes
        'chore',    // Maintenance
        'revert',   // Reverting a commit
      ],
    ],
    'subject-case': [2, 'always', 'lower-case'],
    'subject-max-length': [2, 'always', 72],
    'body-max-line-length': [2, 'always', 100],
  },
};
# Valid commit messages:
git commit -m "feat(auth): add OAuth2 login flow"
git commit -m "fix(api): handle null response from /users endpoint"
git commit -m "docs: update README with new setup instructions"
git commit -m "refactor(hooks): simplify useAuth state management"
git commit -m "test(cart): add unit tests for checkout calculations"

# Invalid messages (commitlint will reject):
git commit -m "Fixed stuff"            # Wrong type format
git commit -m "feat: Add new feature"  # Subject should be lowercase
git commit -m "updated the thing"      # Missing type prefix

Complete Setup

// package.json - Complete configuration
{
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "lint": "eslint .",
    "format": "prettier --write .",
    "typecheck": "tsc --noEmit",
    "test": "vitest run",
    "prepare": "husky"
  },
  "lint-staged": {
    "*.{ts,tsx}": ["eslint --fix", "prettier --write"],
    "*.{json,md,css,yml}": ["prettier --write"]
  },
  "devDependencies": {
    "@commitlint/cli": "^19.0.0",
    "@commitlint/config-conventional": "^19.0.0",
    "eslint": "^9.0.0",
    "husky": "^9.0.0",
    "lint-staged": "^15.0.0",
    "prettier": "^3.0.0"
  }
}

Troubleshooting

# Hooks not running? Check these:
# 1. Make sure .husky directory exists and hooks are executable
ls -la .husky/
chmod +x .husky/pre-commit

# 2. Make sure Husky is installed (prepare script ran)
npm run prepare

# 3. Check if Git hooks directory is set correctly
git config core.hooksPath  # Should be .husky

# 4. Skip hooks temporarily (for emergencies only!)
git commit --no-verify -m "emergency fix"
git push --no-verify

# 5. Debug lint-staged
npx lint-staged --debug

# 6. Test commitlint
echo "feat: test message" | npx commitlint

Git Hooks Best Practices

  • Keep pre-commit hooks fast: Under 10 seconds. Use lint-staged to only check changed files.
  • Run expensive checks in pre-push: Type checking and full test suites should run before push, not before every commit.
  • Also run checks in CI: Hooks can be bypassed with --no-verify. CI is your safety net.
  • Document the setup: New team members need to run npm install to activate hooks (via the prepare script).

Continue Learning