Updated November 2026: rewritten for Angular 22 and Husky 9.
husky installandhusky addare gone, so the hooks are now plain files that we write ourselves. I also fixed the lint-staged configuration (its.htmlpattern 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.0or^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 newinitializes one). - An Angular project with Prettier and ESLint. Create it with
ng new angular-huskyas in the Prettier article, keep its.prettierrcand.prettierignore, and runng add angular-eslintas in the ESLint article.
Install Husky
Install Husky as a development dependency:
npm install husky --save-devThen initialize it, as the Husky documentation shows:
npx husky initThis single command replaces husky install and npm pkg set scripts.prepare from the first version of this article. It:
- adds a
preparescript to package.json. npm runs it after everynpm installornpm ci, so every developer gets the hooks:
"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.hooksPathnow 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 addcommand anymore, and no#!/usr/bin/env shor. "$(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:
npm install @commitlint/cli @commitlint/config-conventional --save-devThe 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:
(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:
export default {
extends: ['@commitlint/config-conventional'],
};Then create the hook, .husky/commit-msg, with a single line (from the commitlint local setup guide):
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:
npm install lint-staged eslint-config-prettier --save-devAdd eslint-config-prettier last in eslint.config.js, so it overrides the configurations before it:
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:
{
"*.{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
.tsin both,eslint --fixandprettier --writecould 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:
npx lint-stagedOn 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 ciin a CI pipeline also runsprepare. Set theHUSKY=0environment 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):
protected sayHello(): string {
let hello = "Hello"
return `${hello}, ${this.title()}!`
}Stage it and commit it with a message that does not follow the convention:
$ 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:
$ 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:
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:
$ 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
- Husky: Get started —
husky init, thepreparescript and hook files. - Husky: How to — skipping hooks in CI with
HUSKY=0, and other recipes. - Git: Git hooks — the client-side hooks,
pre-commitandcommit-msgincluded. - commitlint: Getting started — installation and configuration.
- commitlint: Local setup — the
commit-msghook with Husky. - Conventional Commits 1.0.0 — the commit message specification.
- lint-staged — configuration, glob patterns and task order.
- eslint-config-prettier — turning off the ESLint rules that conflict with Prettier.
- Node.js: Determining module system —
.mjs,.cjsand"type". - npm: Scripts — the
preparelifecycle script. - Angular: Version compatibility — the Node.js versions Angular 22 supports.