I'll be honest — I used to write terrible commit messages.
fixed stuff. update. ok now it works. Sound familiar?
It wasn't until I came back to one of my own projects after a few months and had absolutely no idea what I'd done or why, that I realized I was basically leaving myself (and anyone else) completely in the dark.
So I went deep on Conventional Commits, built this guide as a reference for myself, and now I'm sharing it here. It's the most complete version I could put together. Bookmark it, share it with your team — and let's never write a useless commit message again.
Why Does This Even Matter?
You might be thinking — it's just a commit message, who cares?
Here's the thing. Three months from now, you will care. A new teammate will care. The person reviewing your PR will care.
Bad commit history looks like this:
fixed stuff
update
wip
changes
ok now it works
final final FINAL
That tells you nothing. Good commit history looks like a story — you can scroll through it and actually understand what happened, when, and why.
On top of that, good commits unlock real automation:
- Automatic changelog generation
- Automatic semantic versioning
- CI/CD tools that understand your history
-
git bisectthat actually helps you find bugs
This is also the standard used by Angular, Vue, Electron, ESLint, and thousands of other major open source projects. Once you get it, you won't go back.
The Format
It's simpler than it looks:
<type>(<scope>): <description>
[optional body]
[optional footer(s)]
| Part | Required | Description |
|---|---|---|
type |
✅ | Category of change |
scope |
❌ | Area of the codebase affected |
description |
✅ | Short summary of the change |
body |
❌ | What changed and why |
footer |
❌ | Breaking changes, issue refs, co-authors |
A couple of rules to remember:
- Always leave a blank line between the description and body
- Always leave a blank line between the body and footers
- The
!symbol after type/scope means it's a breaking change
Types — What Goes Where
This is the part most people get wrong. Let me break down every type so there's no guessing.
feat — New Feature
Something new that users or API consumers can actually see or use.
feat: add dark mode toggle
feat(auth): add OAuth2 login support
feat(api): add pagination to /users endpoint
→ MINOR version bump (1.0.0 → 1.1.0)
fix — Bug Fix
Something was broken. Now it isn't.
fix: resolve crash on empty search input
fix(cart): correct total price calculation
fix(mobile): fix nav menu not closing on tap
→ PATCH version bump (1.0.0 → 1.0.1)
chore — Maintenance
All the boring-but-necessary stuff that doesn't touch production behavior or tests.
chore: update dependencies
chore: remove unused imports
chore: clean up .gitignore
chore(deps): bump lodash from 4.17.20 to 4.17.21
→ No version bump.
docs — Documentation
README updates, inline comments, JSDoc, wikis, changelogs — anything that's purely documentation.
docs: add installation guide to README
docs(api): document rate limiting behavior
docs: fix broken links in CONTRIBUTING.md
→ No version bump.
style — Code Formatting
Whitespace, semicolons, import ordering — the stuff your formatter handles. Nothing that changes how the code actually runs.
style: format files with prettier
style(button): fix inconsistent spacing
style: remove trailing whitespace
⚠️ Don't confuse this with CSS or visual UI changes. Use
featorfixfor those.
→ No version bump.
refactor — Code Restructuring
You rewrote something internally — cleaner, simpler, better organized — but the behavior didn't change and no bug was fixed.
refactor: extract validation into separate module
refactor(auth): simplify token refresh logic
refactor: replace forEach with map in data transform
→ No version bump.
test — Tests
Adding, fixing, or updating tests. No changes to production code.
test: add unit tests for useAnimeList hook
test(api): add integration tests for /search endpoint
test: fix flaky timeout in auth tests
→ No version bump.
perf — Performance
Made something faster without changing what it does.
perf: debounce search input to reduce API calls
perf(images): add lazy loading to anime cards
perf: memoize expensive genre filter computation
→ PATCH version bump in some configs.
ci — CI/CD Config
GitHub Actions, CircleCI, Jenkins workflows — anything that touches your pipeline config.
ci: add automated deployment to Vercel
ci: fix failing lint step in GitHub Actions
ci(docker): update Node.js base image to 20-alpine
→ No version bump.
build — Build System
Webpack, Vite, tsconfig, package.json — anything related to how your project gets built.
build: migrate from CRA to Vite
build: add path aliases to tsconfig
build(deps): add recharts as dependency
→ No version bump.
revert — Reverting a Commit
Something went wrong. You're going back.
revert: revert "feat: add experimental offline mode"
Always mention the hash in the body so others know exactly what was undone:
revert: revert "feat: add experimental offline mode"
Reverts commit a1b2c3d.
Caused a regression in the login flow on Safari.
Extended Types
These aren't universal, but you'll see them in larger codebases:
| Type | When to use |
|---|---|
security |
Patching a security vulnerability |
hotfix |
Urgent production fix |
i18n |
Internationalization changes |
a11y |
Accessibility improvements |
infra |
Infrastructure changes (Terraform, Docker, K8s) |
init |
Initial project setup |
wip |
Work in progress — should never stay on main
|
Scopes — Be Specific About What You Touched
Scopes are optional, but once your codebase grows past a certain point, they become really valuable. They tell you where the change happened at a glance.
feat(auth): add JWT refresh logic
fix(navbar): resolve dropdown z-index issue
chore(deps): update react to 18.3.0
A few rules:
- Lowercase only
- One or two words max
-
Pick one name and stick to it —
authandauthenticationbeing used interchangeably is confusing
Common patterns:
# By feature
feat(auth), feat(chat), feat(search), feat(dashboard)
# By layer
fix(api), fix(ui), fix(db), fix(hooks)
# By platform
fix(mobile), fix(ios), fix(web)
# By component
style(AnimeCard), refactor(useAnimeList), test(anilist)
Writing a Good Description
This is where most people slip up. The description is the short summary after the colon — and there are a few rules that matter more than you'd think.
✅ feat: add login page
❌ feat: Added login page
❌ feat: adding login page.
❌ feat: Add Login Page
-
Imperative mood — write it like a command.
add, notaddedoradding - Lowercase the first word (unless it's a proper noun)
- No period at the end
- Under 72 characters — so it doesn't get cut off in terminals or GitHub
-
Be specific —
fix episode count not updating after progress changeis infinitely more useful thanfix bug
The Commit Body — Use It More Than You Do
Most people skip the body entirely. That's a mistake.
The body is where you explain why you made the change — and that's the part that's actually hard to figure out later. The diff already shows what changed.
Here's a good example:
feat(chat): wire recommendations through AniList API
Previously recommendations were pulled from a static local array
of 20 hardcoded titles. Every user got the same suggestions
regardless of what genres they asked for.
Now recommendations query the AniList GraphQL API directly,
filtering by genre and sorting by popularity. Results are
dynamic and always current.
Note: adds a 300-500ms delay on recommendation requests.
Debounced to avoid hammering the endpoint.
Future you will thank present you for this.
Footers — Small Details That Matter
Closes #42
Fixes #17
BREAKING CHANGE: description of what broke
Co-authored-by: Jane Doe <jane@example.com>
Closes #42 is worth the two seconds it takes to type — GitHub and GitLab will automatically close the linked issue when the commit lands on your default branch. It creates a clean paper trail between the code and the original task or bug report.
Breaking Changes
Two ways to flag them:
Method 1 — The ! symbol:
feat!: change exportList to return Promise
feat(api)!: remove deprecated v1 endpoints
Method 2 — Footer:
feat(api): update authentication flow
BREAKING CHANGE: API tokens are no longer accepted as query params.
All requests must include the Authorization header instead.
Both together (best option for clarity):
feat(api)!: remove query param authentication
BREAKING CHANGE: API tokens are no longer accepted as query params.
All requests must include the Authorization header instead.
Migration guide: https://docs.example.com/migration/v2
→ MAJOR version bump (1.0.0 → 2.0.0)
Real-World Examples
Everyday commits
feat: add anime search bar to header
fix: resolve infinite scroll triggering on page load
chore: remove console.log statements
style: format components with prettier
docs: add JSDoc to useAnimeList hook
With scope
feat(chat): add quick action buttons for common queries
feat(stats): add genre breakdown pie chart
fix(card): fix episode progress bar overflow on mobile
fix(anilist): handle null description from API gracefully
Full commit with body and footer
fix(anilist): handle rate limiting from AniList API
AniList enforces a 90 requests/minute rate limit. Under heavy
search usage (fast typing), the app was hitting this and showing
blank results with a cryptic 429 error in the console.
Added exponential backoff retry logic with a max of 3 attempts.
Added a user-facing toast notification when retries are exhausted.
Closes #12
Breaking change
feat(export)!: change exportList format from array to object
Previously exportList returned a raw array of Anime objects.
It now returns an AnimeListState object with `animes` and
`lastUpdated` fields, consistent with the internal state shape.
BREAKING CHANGE: importList now expects an AnimeListState object.
Old JSON backups using the array format will no longer import correctly.
Run the migration script at scripts/migrate-backup.js on old files.
What NOT to Do
Vague descriptions
# Bad
fix: fix
feat: update
chore: stuff
# Good
fix: prevent crash when anime list is empty
feat: add episode progress tracking to anime cards
chore: remove unused StatusBadge variant
Wrong type
# Bad — this is a fix, not a style change
style: fix broken layout on mobile
# Bad — this is a feat, not a refactor
refactor: add search history dropdown
# Good
fix: fix broken layout on mobile
feat: add search history dropdown
Too many changes in one commit
# Bad
feat: add dark mode, fix mobile nav, update README
# Good — three separate commits
feat: add dark mode toggle
fix(nav): fix mobile menu not closing on route change
docs: update README with dark mode usage
Uppercase or period
# Bad
feat: Add new search functionality.
# Good
feat: add new search functionality
How This Connects to Versioning
This is where it gets really powerful. Conventional Commits maps directly to Semantic Versioning (MAJOR.MINOR.PATCH):
| Commit Type | Version Bump | Example |
|---|---|---|
feat |
MINOR — 1.0.0 → 1.1.0
|
New feature added |
fix |
PATCH — 1.0.0 → 1.0.1
|
Bug fixed |
perf |
PATCH — 1.0.0 → 1.0.1
|
Performance improved |
BREAKING CHANGE or !
|
MAJOR — 1.0.0 → 2.0.0
|
API changed |
| Everything else | No bump | Docs, chores, style, etc. |
Tools like semantic-release and release-please can read your commit history and automatically bump the version, generate a changelog, tag a release, and publish to npm — all without you touching a single config value manually.
Setting Up commitlint
You shouldn't rely on willpower to keep commit messages clean. Automate it.
Install
npm install --save-dev @commitlint/cli @commitlint/config-conventional
Configure
// commitlint.config.js
export default {
extends: ['@commitlint/config-conventional'],
};
Test it
echo "feat: add login page" | npx commitlint
# ✅ passes
echo "added login page" | npx commitlint
# ❌ fails with error
Add Git Hooks with Husky
Now wire it up so it runs automatically on every commit:
npm install --save-dev husky
npx husky init
echo "npx --no -- commitlint --edit \$1" > .husky/commit-msg
Done. From this point on, any commit that doesn't follow the convention gets rejected before it even goes through. No more sneaky fix: stuff messages slipping in.
Commitizen — For Teams That Need a Guided Prompt
If your team keeps forgetting the format, commitizen gives everyone an interactive prompt instead:
npm install --save-dev commitizen cz-conventional-changelog
Add to package.json:
{
"config": {
"commitizen": {
"path": "cz-conventional-changelog"
}
}
}
Run npx cz instead of git commit and you'll see:
? Select the type of change:
❯ feat: A new feature
fix: A bug fix
docs: Documentation only changes
style: Changes that do not affect meaning
refactor: A code change that neither fixes a bug nor adds a feature
perf: A code change that improves performance
test: Adding missing tests or correcting existing tests
No memorizing required.
Tips for Teams
1. Agree on scopes upfront. Pick a list, write it down somewhere, and stick to it. Consistency matters more than the specific names.
2. One change per commit. If your description needs the word "and", split it into two commits.
3. Commit often, squash before merging. Work in messy WIP commits locally, then clean them up into proper conventional commits before opening a PR.
4. Body = why, not what. The diff shows what changed. The body is for context that won't be obvious six months from now.
5. Reference issues every time. Closes #42 takes two seconds and creates a permanent connection between your code and the original task.
6. Enforce with tooling, not discipline. People forget. Linters don't. Set up commitlint + Husky and take it off your plate.
Quick Cheatsheet
FORMAT
──────────────────────────────────────────────────────────
<type>(<scope>): <description>
[optional body]
[optional footer(s)]
TYPES
──────────────────────────────────────────────────────────
feat New feature → MINOR version bump
fix Bug fix → PATCH version bump
chore Maintenance, no app change → no bump
docs Documentation only → no bump
style Formatting, no logic → no bump
refactor Restructure, no behavior → no bump
test Tests only → no bump
perf Performance improvement → PATCH version bump
ci CI/CD config → no bump
build Build system / deps → no bump
revert Revert a commit → depends
feat! / BREAKING CHANGE Breaking API → MAJOR version bump
DESCRIPTION RULES
──────────────────────────────────────────────────────────
✅ Imperative mood → "add feature" not "added feature"
✅ Lowercase start → "fix bug" not "Fix bug"
✅ No period at end → "update config" not "update config."
✅ Under 72 chars → keep it short and specific
FOOTER TOKENS
──────────────────────────────────────────────────────────
Closes #42
Fixes #17
BREAKING CHANGE: description of what broke
Co-authored-by: Name <email>
EXAMPLES
──────────────────────────────────────────────────────────
feat: add anime search by genre
feat(chat): wire recommendations through AniList API
fix: resolve crash on empty input
fix(anilist): handle null description gracefully
chore(deps): bump vite from 5.4.0 to 5.4.19
docs: add setup instructions to README
style: format files with prettier
refactor: extract query strings into constants
test: add unit tests for useAnimeList hook
perf: debounce search input to reduce API calls
ci: add GitHub Actions deploy workflow
build: migrate bundler from webpack to vite
revert: revert "feat: add experimental offline mode"
feat!: rename AnimeStatus values to uppercase
Useful Tools
Changelog Generation
| Tool | Description |
|---|---|
| conventional-changelog | Generate changelogs from commit history |
| git-cliff | Highly configurable changelog generator |
| release-please | Google's automated release PR tool |
Automated Releases
| Tool | Description |
|---|---|
| semantic-release | Fully automated versioning and publishing |
| standard-version | Versioning using semver and conventional commits |
Editor Integrations
| Editor | Extension |
|---|---|
| VS Code | Conventional Commits |
| VS Code | Commitizen Support |
| JetBrains | Built-in conventional commit support |
| Vim/Neovim | coc-git |
Further Reading
- conventionalcommits.org — Official specification
- semver.org — Semantic versioning spec
- commitlint.js.org — Linting rules and config
- semantic-release docs — Automated release pipeline
- Angular commit guidelines — Where the convention originated
If this helped, the full guide lives on GitHub / Read this blog on my portfolio
— Ovi ren
Top comments (0)