Updated November 2026: rewritten for Angular 22 and Husky 9. husky install and husky add are gone, so the hooks are now plain files that we write ourselves. I also fixed the lint-staged configuration (its .html pattern never matched), and every output below comes from real commits.

Husky manages Git hooks: scripts that Git runs before or after an action such as a commit or a push. Combined with commitlint and lint-staged, it enforces commit message conventions and code quality before anything reaches the repository.

In this article, we install Husky, commitlint and lint-staged in an Angular 22 project. It is the last article of a series: it relies on the Prettier and ESLint setups of Configure Prettier with Angular and Configure ESLint with Angular.

Prerequisites

  • Node.js: Angular 22 requires ^22.22.3, ^24.15.0 or ^26.0.0 (see Version compatibility); commitlint 21 needs Node.js 22.12 or later.
  • Angular CLI and Git: Husky manages Git hooks, so the project must be a Git repository (ng new initializes one).
  • An Angular project with Prettier and ESLint. Create it with ng new angular-husky as in the Prettier article, keep its .prettierrc and .prettierignore, and run ng add angular-eslint as in the ESLint article.

Install Husky

Install Husky as a development dependency:

Copy
npm install husky --save-dev

Then initialize it, as the Husky documentation shows:

Copy
npx husky init

This single command replaces husky install and npm pkg set scripts.prepare from the first version of this article. It:

  • adds a prepare script to package.json. npm runs it after every npm install or npm ci, so every developer gets the hooks:
Copy
"scripts": {
  "prepare": "husky"
}
  • creates a .husky/pre-commit hook that runs npm test (we replace it below);
  • points Git to the hooks of the project (git config core.hooksPath now returns .husky/_).

Note: With Husky 9, a hook is a plain shell file in the .husky directory, named after the Git hook. There is no husky add command anymore, and no #!/usr/bin/env sh or . "$(dirname -- "$0")/_/husky.sh" lines at the top of the files.

Install and configure commitlint

commitlint checks that commit messages follow a convention. Install the CLI and the Conventional Commits configuration:

Copy
npm install @commitlint/cli @commitlint/config-conventional --save-dev

The commitlint documentation writes the configuration as an ES module (export default). But the package.json of an Angular project has no "type": "module", so a commitlint.config.js file with export default works, but Node.js prints a warning on every commit:

Copy
(node:12956) [MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of file://~/angular-husky/commitlint.config.js is not specified and it doesn't parse as CommonJS.
Reparsing as ES module because module syntax was detected. This incurs a performance overhead.
To eliminate this warning, add "type": "module" to ~/angular-husky/package.json.

Adding "type": "module" would break the eslint.config.js generated by angular-eslint, which uses require. Use the .mjs extension instead: it tells Node.js that the file is an ES module. Create commitlint.config.mjs at the root of the project:

Copy
export default {
  extends: ['@commitlint/config-conventional'],
};

Then create the hook, .husky/commit-msg, with a single line (from the commitlint local setup guide):

Copy
npx --no -- commitlint --edit "$1"

Git passes the path of the commit message file as $1, and commitlint validates the message against the configured rules. To try it without committing, run echo "add a greeting" | npx commitlint: the message has no type, so commitlint exits with an error, and the hook would reject the commit. A message like feat: add a greeting passes.

Install and configure lint-staged

lint-staged runs tools such as ESLint and Prettier on the staged files only, so a commit never waits for the whole project to be linted. Install it, with eslint-config-prettier, which turns off the ESLint rules that are unnecessary or might conflict with Prettier:

Copy
npm install lint-staged eslint-config-prettier --save-dev

Add eslint-config-prettier last in eslint.config.js, so it overrides the configurations before it:

Copy
const angular = require('angular-eslint');
const eslintConfigPrettier = require('eslint-config-prettier/flat');

module.exports = defineConfig([
  // ... the two objects generated by angular-eslint
  eslintConfigPrettier,
]);

Then create .lintstagedrc.json at the root of the project:

Copy
{
  "*.{ts,html}": ["eslint --fix", "prettier --write"],
  "*.{json,css,scss,less,md}": ["prettier --write"]
}

Three things changed since the first version of this article:

  • The pattern was "*.{ts, html}". With the space, the second extension is " html", so .html files never matched and were never linted.
  • The two patterns no longer overlap. lint-staged runs different patterns concurrently, so with .ts in both, eslint --fix and prettier --write could edit the same file at the same time. Inside an array, the commands run in order: ESLint first, then Prettier.
  • The file was named .lintstagedrc.js while it contained JSON. A JSON configuration goes in .lintstagedrc.json (or in a "lint-staged" key of package.json).

Finally, replace the content of .husky/pre-commit with:

Copy
npx lint-staged

On each commit, Git now runs the pre-commit hook first: lint-staged fixes and checks the staged files, and aborts the commit on an error it cannot fix. Then Git runs the commit-msg hook: commitlint validates the message and aborts the commit if it is invalid. The commit is created only if both hooks pass.

Note: npm ci in a CI pipeline also runs prepare. Set the HUSKY=0 environment variable to skip installing the hooks where nobody commits (see How to).

Let's try it

Let's add a method to app.ts, written carelessly (let, double quotes, no semicolons):

Copy
  protected sayHello(): string {
    let hello = "Hello"
    return `${hello}, ${this.title()}!`
  }

Stage it and commit it with a message that does not follow the convention:

Copy
$ git add src/app/app.ts
$ git commit -m "add a greeting"
⋯ Backing up original state…
✔ Done backing up original state (5507c60)!
⋯ Running tasks for staged files…
    *.{ts,html} — 1 file
      ⋯ eslint --fix
      ⋯ prettier --write

✔ eslint --fix
✔ prettier --write

✔ Done running tasks for staged files!
⋯ Staging changes from tasks…
✔ Done staging changes from tasks!
⋯ Cleaning up temporary files…
✔ Done cleaning up temporary files!
⧗   --- input ---
add a greeting
✖   subject may not be empty [subject-empty]
✖   type may not be empty [type-empty]

✖   found 2 problems, 0 warnings
ⓘ   Get help: https://github.com/conventional-changelog/commitlint/#what-is-commitlint

husky - commit-msg script failed (code 1)

The pre-commit hook passed, then commitlint rejected the message: no commit was created. The fixes made by lint-staged stay staged, so we only have to commit again with a valid message. lint-staged runs again, then:

Copy
$ git commit -m "feat: add a greeting"
...
✔ Done cleaning up temporary files!
[demo 6ab3bac] feat: add a greeting
 1 file changed, 5 insertions(+)

Here is what was committed:

Copy
  protected sayHello(): string {
    const hello = 'Hello';
    return `${hello}, ${this.title()}!`;
  }

ESLint replaced let with const, then Prettier added the single quotes and the semicolons.

What if ESLint finds a problem that it cannot fix? Let's add protected data: any = {}; to the component and commit again:

Copy
$ git commit -m "feat: keep some data"
⋯ Backing up original state…
✔ Done backing up original state (e46d5b3)!
⋯ Running tasks for staged files…
    *.{ts,html} — 1 file
      ⋯ eslint --fix
      ⋯ prettier --write

✖ eslint --fix
↓ prettier --write

✖ Failed to run tasks for staged files!
↓ Skipped staging changes from tasks…
⋯ Reverting to original state because of errors…
✔ Done reverting to original state!
⋯ Cleaning up temporary files…
✔ Done cleaning up temporary files!

✖ eslint --fix:

~/angular-husky/src/app/app.ts
  12:19  error  Unexpected any. Specify a different type  @typescript-eslint/no-explicit-any

✖ 1 problem (1 error, 0 warnings)
husky - pre-commit script failed (code 1)

The commit is aborted and the files are back to their original state. We only have to give data a real type.

Conclusion

In this article, we set up Husky with commitlint and lint-staged in an Angular project: the pre-commit hook fixes and checks the staged files, and the commit-msg hook enforces Conventional Commits.

Husky can run any command on any Git hook. For example, to run the tests before each push, create a .husky/pre-push file containing npm test -- --watch=false.

Source code of the article: GitHub source code

References