Standardize Git Commits with Commitizen, Husky, and Commitlint
Messy commit histories are a silent tax on every team. Messages like fix stuff, wip, or update 2 final FINAL make it impossible to understand what changed, why it changed, or when a bug was introduced.
Conventional Commits is a lightweight spec that solves this. Combined with three tools — Commitizen, Commitlint, and Husky — you get an interactive commit workflow that guides developers to write good messages and automatically rejects bad ones.
This guide walks through setting up all three in a Node.js project.
What Each Tool Does
| Tool | Role |
|---|---|
| Commitizen | Interactive CLI prompt that builds a valid commit message step by step |
| Commitlint | Lints commit messages against the Conventional Commits spec |
| Husky | Runs scripts via Git hooks — used here to trigger Commitlint on every commit |
The Conventional Commits Format
Before diving in, here’s the structure you’ll be enforcing:
<type>(<scope>): <short description>
[optional body]
[optional footer]
Examples:
feat(auth): add OAuth login
fix(api): handle null response from payment service
chore: update dependencies
docs(readme): add installation instructions
Valid types: feat, fix, docs, style, refactor, perf, test, chore, ci
Installation
Install all three tools as dev dependencies:
npm install --save-dev commitizen cz-conventional-changelog @commitlint/cli @commitlint/config-conventional husky
Step 1 — Configure Commitizen
Commitizen needs an adapter that defines the prompt style. cz-conventional-changelog implements the Conventional Commits spec.
Add the following to your package.json:
{
"scripts": {
"commit": "cz",
"prepare": "husky"
},
"config": {
"commitizen": {
"path": "./node_modules/cz-conventional-changelog"
}
}
}
The prepare script runs automatically after npm install, ensuring Husky hooks are always registered when a teammate clones the repo.
Step 2 — Configure Commitlint
Create a commitlint.config.js in your project root.
For ESM projects ("type": "module" in package.json):
export default { extends: ['@commitlint/config-conventional'] };
For CommonJS projects:
module.exports = { extends: ['@commitlint/config-conventional'] };
How to tell which you have: Check your
package.json. If it has"type": "module", use the ESM syntax. If it’s absent or set to"commonjs", use the CommonJS syntax.
Step 3 — Set Up Husky
Initialize Husky, which creates the .husky/ directory:
npx husky init
This also creates a default .husky/pre-commit file that runs npm test. Update it to something your project actually has — for example, a typecheck:
# .husky/pre-commit
npm run typecheck
Now create the commit-msg hook that runs Commitlint:
echo 'npx --no -- commitlint --edit $1' > .husky/commit-msg
This hook fires after you write a commit message. If the message doesn’t conform to the spec, the commit is aborted with a clear error.
How It Works Together
Here’s the full flow when a developer commits:
git add .
npm run commit # or: git cz / npx cz
Commitizen launches an interactive prompt:
? Select the type of change:
feat: A new feature
fix: A bug fix
docs: Documentation only changes
refactor: A code change that neither fixes a bug nor adds a feature
...
? What is the scope of this change? (e.g. auth, api, ui): auth
? Write a short description:
add OAuth login
? Provide a longer description (press enter to skip):
? Are there any breaking changes? No
? Does this change close any open issues? Yes
? Add issue references: #42
Commitizen assembles the final message:
feat(auth): add OAuth login
Closes #42
Husky’s commit-msg hook then passes this to Commitlint for validation. Since it’s well-formed, the commit succeeds.
If someone bypasses Commitizen and runs git commit -m "fix stuff" directly, Commitlint catches it:
⧗ input: fix stuff
✖ subject may not be empty [subject-empty]
✖ type may not be empty [type-empty]
✖ found 2 problems, 0 warnings
Final package.json Structure
{
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "tsc --noEmit",
"commit": "cz",
"prepare": "husky"
},
"config": {
"commitizen": {
"path": "./node_modules/cz-conventional-changelog"
}
},
"devDependencies": {
"@commitlint/cli": "^20.0.0",
"@commitlint/config-conventional": "^20.0.0",
"commitizen": "^4.3.1",
"cz-conventional-changelog": "^3.3.0",
"husky": "^9.0.0"
}
}
Verification Checklist
After setup, confirm everything works:
-
npm run commit— Commitizen prompt appears -
git commit -m "bad message"— rejected by thecommit-msghook -
git commit -m "feat: add something"— passes through cleanly - Fresh
npm installon a clone — Husky hooks are installed automatically via thepreparescript
What’s Next
- Auto-generate changelogs with
standard-versionorrelease-it, which parse your commit history to produce aCHANGELOG.md - Custom commit types — swap
cz-conventional-changelogforcz-customizableto define your own prompt and types - CI enforcement — add Commitlint to your CI pipeline to catch any commits that slipped past the hook (e.g., direct pushes to main)
With this setup, every commit in your repo carries a consistent, machine-readable message — making git history actually useful for debugging, reviewing, and releasing.