Overview
This article documents the exact setup used in <project's>_api* — a Node.js REST API built with Express 5, Prisma, TypeScript, and Bun — to enforce linting and formatting on every Git commit using Husky and Biome.
What this covers:
- How Git hooks work and why raw hooks are insufficient for team projects
- How Husky solves the version-control problem for hooks
- How Biome replaces ESLint + Prettier in a single tool
- The complete configuration for a TypeScript/Prisma project
- Extending the hook with tests once test files exist
How Git Hooks Work
Git hooks are shell scripts that Git executes automatically at specific lifecycle points. The relevant hooks for enforcing code quality are:
| Hook | Trigger |
|---|---|
pre-commit |
Before the commit is recorded |
commit-msg |
After the commit message is written |
pre-push |
Before pushing to a remote |
The pre-commit hook fires before Git writes anything. If the script exits with code 1, Git aborts the commit entirely and nothing is recorded. If it exits with code 0, Git proceeds normally.
git commit
↓
pre-commit script runs
↓
Exit 0 → commit recorded
Exit 1 → commit aborted, working tree unchanged
The Problem With Raw Git Hooks
Raw hooks live in .git/hooks/. This directory is explicitly excluded from version control — it does not exist in the remote repository and is never cloned. This means:
- Every developer must manually create and configure hooks after cloning
- Hook scripts cannot be peer-reviewed through PRs
- Hook updates must be communicated and applied manually by every team member
- CI pipelines cannot validate that hooks are properly configured
This makes raw hooks unreliable in any multi-developer context.
How Husky Solves This
Husky uses the Git configuration key core.hooksPath to redirect Git's hook resolution to a custom directory — .husky/ — which lives in the project root and is tracked by version control.
# What Husky sets under the hood
git config core.hooksPath .husky
This means the .husky/ directory is committed to the repository, versioned, code-reviewed, and cloned alongside the rest of the project.
Husky also adds a prepare lifecycle script to package.json:
{
"scripts": {
"prepare": "husky"
}
}
The prepare script runs automatically when any developer runs bun install (or npm install). This means hooks are configured silently on every fresh clone — no manual steps required.
Husky characteristics:
- 2kb gzipped, zero runtime dependencies
- Supports all 13 client-side Git hooks
- Works with any package manager: npm, pnpm, yarn, bun
- Compatible with monorepos and nested projects
Why Biome Instead of ESLint + Prettier
The traditional approach uses two separate tools:
- ESLint for lint rules (detecting code problems)
- Prettier for formatting (enforcing consistent style)
This means two config files, two separate passes, two tools to keep in sync, and occasional conflicts when ESLint formatting rules clash with Prettier's output.
Biome is a single Rust-based tool that handles both. One config file (biome.json), one command, one pass. It is significantly faster than the JavaScript-based alternatives and produces no conflicts between lint and format rules because they are handled by the same engine.
For a TypeScript project using Bun, Biome is a natural fit — it is invoked via bunx --bun @biomejs/biome which runs the native binary directly without spawning a Node.js process.
Project Setup
Installation
# Install Husky
bun add --dev husky
# Initialise — creates .husky/ and adds prepare script to package.json
bunx husky init
After initialisation, the project structure gains:
project-root/
├── .husky/
│ └── pre-commit ← created by husky init (edit this)
├── biome.json
├── package.json ← "prepare": "husky" added automatically
└── bun.lock
The Pre-Commit Hook
Edit .husky/pre-commit:
#!/bin/sh
bunx --bun @biomejs/biome check --write .
Flags explained:
| Flag | Behaviour |
|---|---|
check |
Run both linter and formatter in a single pass |
--write |
Auto-apply safe fixes (formatting, trivial lint fixes) |
. |
Apply to the entire project (respects files.includes in biome.json) |
If Biome encounters lint errors it cannot auto-fix — a real code problem — it exits with code 1 and the commit is aborted. If all checks pass (or only auto-fixable issues were found), it exits with code 0 and the commit proceeds.
Biome Configuration
The biome.json for JoseOcando_api:
{
"$schema": "https://biomejs.dev/schemas/2.4.6/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"includes": ["**", "!!**/dist"]
},
"formatter": {
"enabled": true,
"indentStyle": "tab"
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
},
"assist": {
"actions": {
"source": {
"organizeImports": "off"
}
}
},
"linter": {
"rules": {
"suspicious": {
"useIterableCallbackReturn": "off",
"noExplicitAny": "off"
},
"style": {
"useImportType": "off"
},
"correctness": {
"noUnusedFunctionParameters": "off",
"noUnusedImports": "off"
}
}
}
}
Configuration decisions:
-
vcs.useIgnoreFile: true— Biome reads.gitignoreand skips ignored files (e.g.node_modules,src/generated/) -
files.includes: ["!!**/dist"]— explicitly excludes the compiled output directory -
indentStyle: "tab"— tabs for indentation (consistent with the TypeScript config) -
quoteStyle: "double"— double quotes for JavaScript/TypeScript strings -
noExplicitAny: "off"— Prisma service functions usepayload: anyduring schema iteration; this rule is disabled until the schema stabilises -
noUnusedImports: "off"— disabled to avoid noise on files that are still being actively developed -
organizeImports: "off"— import ordering is handled manually to avoid conflicts with Prisma-generated imports
Rules should be tightened over time. Starting with fewer rules enforced is preferable to fighting the linter constantly during early development.
What the Hook Produces
On a passing commit
❯ git commit -m "feat: add experience endpoint"
» Running pre-commit hook...
✔ Biome: no issues found (12 files checked)
[main 4f2a1c9] feat: add experience endpoint
3 files changed, 47 insertions(+)
On a failing commit
❯ git commit -m "feat: add experience endpoint"
» Running pre-commit hook...
src/app/module/Experiance/exp.validation.ts:1:8 lint/correctness/noUnusedImports
Imported variable 'z' is defined but never used.
✖ Found 1 error.
husky - pre-commit script failed (code 1)
The commit is aborted. The working tree is unchanged. The developer fixes the error and commits again.
The Default Hook Pitfall
When bunx husky init runs, it generates a default .husky/pre-commit that contains:
npm test
(or bun test if Bun is detected)
If your project has no test files yet, this will immediately fail:
bun test v1.3.5
No tests found!
Tests need ".test", "_test_", ".spec" or "_spec_" in the filename
husky - pre-commit script failed (code 1)
This is correct behaviour — bun test exited with code 1, so Husky blocked the commit. But the cause is a missing test suite, not a code problem.
Resolution: Replace the default hook content with a script that runs something immediately useful. Biome is the correct choice here since it is already configured and provides real value from the first commit.
Do not add bun test back to the hook until actual test files exist. An empty test suite failing the hook on every commit defeats the purpose of having a hook.
Extending the Hook With Tests
Once test files exist, extend .husky/pre-commit:
#!/bin/sh
bunx --bun @biomejs/biome check --write .
bun test
For an Express/Prisma project, start with tests for utilities that have no database dependencies. These run instantly and require no environment setup.
Recommended starting points in JoseOcando_api:
| File | Why it's a good first test |
|---|---|
src/utils/jwtUtils.ts |
Pure functions, no DB, no HTTP |
src/shared/catchAsync.ts |
Wraps a handler, fully synchronous |
src/shared/sendResponse.ts |
Builds a response object, no side effects |
Example — src/utils/jwtUtils.test.ts:
import { describe, expect, it } from "bun:test"
import { jwtUtils } from "./jwtUtils"
const SECRET = "test_secret_key_32_chars_minimum"
const PAYLOAD = { userId: 1, email: "user@example.com", role: "VISITOR" }
describe("jwtUtils.generateToken", () => {
it("returns a string with three dot-separated segments", () => {
const token = jwtUtils.generateToken(PAYLOAD, SECRET, "1h")
expect(typeof token).toBe("string")
expect(token.split(".").length).toBe(3)
})
})
describe("jwtUtils.verifyToken", () => {
it("returns the original payload on a valid token", () => {
const token = jwtUtils.generateToken(PAYLOAD, SECRET, "1h")
const result = jwtUtils.verifyToken(token, SECRET) as typeof PAYLOAD
expect(result.userId).toBe(PAYLOAD.userId)
expect(result.email).toBe(PAYLOAD.email)
})
it("throws JsonWebTokenError on a malformed token", () => {
expect(() =>
jwtUtils.verifyToken("not.a.valid.jwt", SECRET)
).toThrow()
})
it("throws TokenExpiredError on an expired token", async () => {
const token = jwtUtils.generateToken(PAYLOAD, SECRET, "0s")
await new Promise(r => setTimeout(r, 10)) // ensure expiry
expect(() =>
jwtUtils.verifyToken(token, SECRET)
).toThrow()
})
})
Bypassing the Hook
For work-in-progress commits on feature branches:
# Environment variable method
HUSKY=0 git commit -m "wip: draft implementation"
# Git native flag
git commit --no-verify -m "wip: draft implementation"
Both methods skip all hooks entirely. HUSKY=0 is Husky-specific and more explicit about intent. --no-verify is a Git primitive that bypasses all hooks regardless of whether Husky is involved.
Policy: Never bypass hooks on main or any branch that feeds into a deployment pipeline. Bypassing should be limited to local WIP branches that will be cleaned up (rebased or squash-merged) before merging.
Summary
| Component | Role |
|---|---|
| Husky | Manages Git hook lifecycle; makes hooks version-controlled |
core.hooksPath |
Git config key Husky sets to redirect hook resolution |
.husky/pre-commit |
Shell script executed before every commit |
| Biome | Performs lint + format check; exits 1 on unfixable errors |
prepare script |
Runs husky on bun install; installs hooks on every clone |
The complete hook for a project with tests:
#!/bin/sh
bunx --bun @biomejs/biome check --write .
bun test
For a project without tests yet:
#!/bin/sh
bunx --bun @biomejs/biome check --write .
Add bun test to the hook only when test files exist. A hook that always fails provides no value and trains developers to bypass it.
Stack: Express 5 · Prisma 7 · TypeScript 5.9 · Bun 1.3 · Biome 2.4 · Husky 9
Top comments (0)