Husky + Prettier + lint-staged Setup for Next.js
Set up Husky v9, Prettier, and lint-staged in your Next.js project. Step-by-step guide covering pre-commit hooks with the correct 2026 config.
On this page
Most code quality issues are caught too late: in code review, or worse, in production. Husky, Prettier, and lint-staged give you an automatic check on every single commit, right on your machine, before the code ever leaves your editor.
This guide walks through the full setup for a Next.js project using the current versions of all three tools. If you've tried this before with a guide from 2022 and it didn't work, it's probably because Husky changed its configuration format in v9.
- 1
What These Tools Do
Three tools, each with a distinct job. Together they form an automatic quality gate that runs on every commit:
Prettierformats your code automatically. You set the rules once (line length, single vs double quotes, trailing commas) and it handles the rest.lint-stagedruns linters only on the files you've staged for a commit, not your entire codebase. This keeps the pre-commit check fast even on large projects.Huskyconnects everything to Git. It installs scripts that run automatically at key moments: in this setup, immediately before a commit is finalized.
- 2
Install the Tools
From your Next.js project root, install the three dev dependencies. Next.js already ships with ESLint configured, so you don't need to install that separately.
bashnpm install --save-dev prettier husky lint-staged - 3
Configure Prettier
Create a
.prettierrcfile in your project root. These are sensible defaults for a Next.js TypeScript project (adjust them to your team's preference):json{ "semi": true, "singleQuote": true, "trailingComma": "es5", "printWidth": 100, "tabWidth": 2 } - 4
Initialize Husky v9
This is where most guides go wrong. Husky v9 dropped the old
.huskyrcfile format. Run one command to initialize it:bashnpx husky init - 5
Configure the Pre-Commit Hook
Open
.husky/pre-commit(it was just created byhusky init). Replace its contents with a single line that triggerslint-staged:shnpx lint-staged - 6
Configure lint-staged
Add a
lint-stagedkey to yourpackage.json. This tells it which tools to run on which file types when they're staged:json{ "lint-staged": { "*.{js,jsx,ts,tsx}": [ "eslint --fix", "prettier --write" ], "*.{json,css,md}": [ "prettier --write" ] } } - 7
Test the Setup
Stage a file with a formatting issue and try to commit.
lint-stagedwill run both tools automatically:bashgit add . git commit -m "test commit"Here's what each outcome looks like in your terminal:
bash — Success: Prettier auto-fixed, commit goes through✔ Preparing lint-staged... ✔ Running tasks for staged files... ✔ Applying modifications from tasks... ✔ Cleaning up temporary files... [main abc1234] test commit 1 file changed, 5 insertions(+)bash — Failure: ESLint error blocks the commit✔ Preparing lint-staged... ⚠ Running tasks for staged files... ✖ eslint --fix: src/app/page.tsx 5:7 error 'myVar' is assigned a value but never used no-unused-vars ✗ lint-staged failed due to a task error. - 8
Common Issues and Fixes
These are the four most common problems developers hit after setting this up:
Create the pre-push hook for TypeScript checking:
sh — .husky/pre-pushnpx tsc --noEmit
Migrating an Existing Husky v8 Setup
If your project already had Husky before v9, the upgrade is mostly deletion. Husky v9 removed the shebang line, the husky.sh source line, and the husky install command. Hook files are now plain shell scripts with no boilerplate at all.
The migration itself is three commands. Upgrade the package, strip the old preamble from each hook file, and swap the prepare script.
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx lint-stagednpx lint-stagednpm install --save-dev husky@latest
npm pkg set scripts.prepare="husky"
npm run prepareUnder the hood, v9 points Git at .husky/_ rather than .git/hooks by setting core.hooksPath. That directory is generated by the prepare script and should be git-ignored. Husky writes a .husky/_/.gitignore for you, so in most projects there is nothing to do.
If you keep your own prepare script for other setup work, chain husky into it rather than replacing it. A prepare script that does not call husky means hooks silently stop installing for everyone who clones the repo.
Working with ESLint Flat Config
ESLint 9 replaced .eslintrc.json with a flat config file, eslint.config.mjs, and Next.js 16 dropped the next lint wrapper entirely in favour of calling the ESLint CLI directly. Both changes affect what you put in your lint-staged config.
The important practical difference is how ESLint treats a file that you pass explicitly on the command line but that your config ignores. Under flat config, ESLint emits a warning instead of quietly skipping the file. Because lint-staged passes staged file paths as arguments, any staged file inside an ignored directory produces that warning, and if you run with --max-warnings=0 the commit fails for no real reason.
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { FlatCompat } from "@eslint/eslintrc";
const compat = new FlatCompat({
baseDirectory: dirname(fileURLToPath(import.meta.url)),
});
export default [
{ ignores: [".next/**", "out/**", "node_modules/**", "coverage/**"] },
...compat.extends("next/core-web-vitals", "next/typescript"),
{
rules: {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
},
},
];{
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"eslint --fix --no-warn-ignored --max-warnings=0",
"prettier --write"
],
"*.{json,css,md,yml,yaml}": [
"prettier --write"
]
}
}One more overlap to remove: if any of your ESLint rules control formatting (quote style, semicolons, indentation), they will fight Prettier and produce a commit that flips back and forth. Install eslint-config-prettier and put it last in the config array so it turns those rules off.
Type-aware rules deserve a separate warning. Enabling projectService or parserOptions.project makes ESLint load the whole TypeScript program on every run, which turns a 300ms pre-commit hook into a 12 second one. If you want type-aware linting, keep it out of the commit hook and run it in CI. The same trade-off shows up in the TypeScript 7 tooling breakage roundup.
Keeping the Pre-Commit Hook Fast
A pre-commit hook has one hard constraint: developers must not notice it. Once it crosses roughly two seconds, people start reaching for --no-verify out of habit, and at that point the hook has stopped protecting anything.
Most slow hooks are slow for one of four reasons. Work through them in this order, because the fixes get progressively more invasive.
| Symptom | Cause | Fix |
|---|---|---|
| Hook takes 10s+ regardless of how many files changed | A whole-project command (`tsc --noEmit`, `next build`, `jest`) is in the hook | Move it to `pre-push` or CI |
| Hook is slow only on TS/TSX files | Type-aware ESLint rules loading the full TS program | Drop `projectService` from the lint-staged run |
| Second commit is as slow as the first | No lint cache | Add `--cache` to both ESLint and Prettier |
| Slow on a single large staged file | Repeated tool startup cost per matched pattern | Merge patterns so each tool is invoked once |
{
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"eslint --fix --cache --cache-location .eslintcache --no-warn-ignored",
"prettier --write --cache"
],
"*.{json,css,md,yml,yaml}": "prettier --write --cache"
}
}lint-staged already runs different glob patterns concurrently, so splitting work across more patterns does not usually help. What does help is reducing how many times a binary starts up, since Node process startup is a fixed cost you pay per invocation.
If you genuinely need a project-wide check before code leaves the machine, use a pre-push hook. Pushing happens far less often than committing, so a five second wait there is acceptable in a way that the same wait on every commit is not.
npm run typecheck
npm run test -- --run --passWithNoTestsDefine those as real npm scripts rather than inlining the commands, so the hook and CI call exactly the same thing. If you are not already treating package.json scripts as a shared interface, the npm scripts guide covers the patterns worth adopting.
Husky and lint-staged in a Monorepo
Git hooks are a property of the repository, not of a package. There is exactly one .git directory, so there is exactly one Husky installation, and it belongs at the workspace root. Installing Husky inside apps/web and expecting it to work is the single most common monorepo mistake.
That creates a wrinkle when the repository root is not the same directory as the Next.js app. Husky handles it with a path argument on the prepare script.
my-monorepo/
.git/
.husky/
pre-commit
package.json <- husky + lint-staged installed here
apps/
web/ <- Next.js app
package.json
lint-staged.config.mjs
packages/
ui/
package.json
lint-staged.config.mjs{
"private": true,
"workspaces": ["apps/*", "packages/*"],
"scripts": {
"prepare": "husky"
},
"devDependencies": {
"husky": "^9.1.7",
"lint-staged": "^15.5.0",
"prettier": "^3.5.0"
}
}For the linting side, lint-staged resolves the nearest config file to each staged file. That means each workspace package can own its own rules, and a commit touching both apps/web and packages/ui runs each package's tasks against only its own files. You do not need to script the routing yourself.
Keep the root config minimal and let packages specialise. A root lint-staged.config.mjs is still useful as a fallback for files that live outside any package, such as CI workflow files, the root README, and shared tooling config.
export default {
"*.{ts,tsx}": [
"eslint --fix --no-warn-ignored --cache",
"prettier --write --cache",
],
"*.{json,css,md}": "prettier --write --cache",
};Running the Same Checks in CI Without Doing the Work Twice
A pre-commit hook is a convenience, not a guarantee. Anyone can bypass it, and a pull request opened from the GitHub web UI never runs it at all. CI is where the checks become enforceable, so the rules that block a merge have to live there too.
The mistake to avoid is letting Husky run inside CI. During npm ci, the prepare script executes and installs hooks into a checkout that will never make a commit. It is wasted time at best, and on some runners it fails outright because git metadata is shallow or missing.
name: Quality
on:
pull_request:
push:
branches: [main]
jobs:
checks:
runs-on: ubuntu-latest
env:
HUSKY: 0
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- name: Formatting
run: npx prettier --check .
- name: Lint
run: npx eslint .
- name: Types
run: npx tsc --noEmitNote the asymmetry between the two environments. Locally you run prettier --write because you want files fixed in place before they are committed. In CI you run prettier --check, which writes nothing and exits non-zero when a file is misformatted. Running --write in CI would produce a green build on top of changes nobody ever sees.
Setting HUSKY: 0 at the job level covers every step, including any npm ci in a later job stage. If you deploy from the same workflow, that single environment variable is usually all you need. For a fuller walkthrough of the workflow syntax, see the GitHub Actions tutorial.
If a full-repository lint is too slow for your CI budget, scope it to the files the pull request actually touched instead of falling back to lint-staged. A diff against the merge base gives you the same speed benefit with none of the staging-area assumptions.
git fetch --no-tags --depth=50 origin main
CHANGED=$(git diff --name-only --diff-filter=ACMR origin/main...HEAD -- '*.ts' '*.tsx')
if [ -n "$CHANGED" ]; then
npx eslint --no-warn-ignored $CHANGED
npx prettier --check $CHANGED
fiWhen Hooks Do Not Fire at All
Silence is the worst failure mode. A hook that errors loudly gets fixed within minutes, but a hook that never runs can go unnoticed for weeks while unformatted code accumulates. Verify installation directly rather than trusting that it worked.
Start with the one command that tells you whether Git even knows about Husky. On a correct v9 setup it prints .husky/_ and nothing else.
git config core.hooksPath
ls -la .husky/_/pre-commit
git commit --allow-empty -m "hook smoke test"If the first command prints nothing, Husky was never installed in this clone. If it prints .git/hooks, something overwrote the setting, often an older tooling migration or a git config line in an onboarding script. Run npm run prepare and check again.
The empty commit in the third line is the fastest end-to-end test available. It exercises the real hook path without needing a file to stage, and you can drop it afterwards with git reset --hard HEAD~1.
| What you see | Actual cause | Fix |
|---|---|---|
| Nothing happens on commit, no output | `prepare` never ran (`npm ci --ignore-scripts`, or a CI-style install locally) | Run `npm run prepare` once |
| `.husky/pre-commit: Permission denied` | Hook file lost its executable bit, usually via a manual edit or a patch | `chmod +x .husky/pre-commit` |
| `husky - command not found` or a `\r` in the error | CRLF line endings on the hook file | Add `.husky/** text eol=lf` to `.gitattributes` |
| Works in the terminal, silent in your Git GUI | The GUI launches a non-login shell where `node` is not on PATH | Add a `~/.config/husky/init.sh` that sources your version manager |
| Hooks fire, but no files are ever checked | `lint-staged` glob patterns do not match your file extensions | Run `npx lint-staged --debug` to see the matched file list |
The Git GUI case deserves elaboration because it is genuinely confusing: the setup works perfectly from your terminal and does nothing from Tower, GitHub Desktop, or the VS Code source control panel. Those applications inherit a minimal environment that never sources your shell profile, so nvm, fnm, and volta shims are missing and the hook cannot find node.
Husky v9 sources ~/.config/husky/init.sh before every hook specifically to solve this. Put your version manager setup there once and every hook, in every repository, gets a working PATH.
# Sourced before every Husky hook, in every repository.
# Makes node available to Git GUI clients that skip shell profiles.
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"Bypassing Hooks Without Wrecking the Codebase
--no-verify has a bad reputation it only partly deserves. There are situations where skipping the hook is the correct engineering decision, and pretending otherwise just teaches people to alias it permanently.
The legitimate cases share a shape: the hook is checking something that is not relevant to the commit you are making right now.
- Committing work in progress on a private branch. Half-written code fails lint by definition. Skip the hook, then let the pre-push or CI check catch anything real before the branch becomes a pull request.
- A production incident at 3am. A one-line revert should not be blocked by an unused import somewhere else in the file. Ship the fix, open a follow-up.
- Bulk mechanical changes. A codemod across 400 files will run the linter on all of them, and you have already reviewed the transform. Skip the hook and run the check once, deliberately, afterwards.
- The hook itself is broken. If
prepareproduced a bad hook, you need a way to commit the fix for it.
git commit --no-verify -m "wip: partial refactor"
# Same flag exists on push
git push --no-verifyTwo habits keep this honest. First, treat every bypassed commit as debt that gets paid before the pull request opens, not after review starts. Second, make CI the actual gate, so a bypassed hook costs you a red build rather than shipping bad code.
It is also worth separating concerns here. If you have a commit-msg hook validating Conventional Commits, --no-verify skips that too, which is how malformed messages end up in a release changelog. You can check a message against the spec independently with the commit message checker before you push.
Setup Verification Checklist
Work through this on a fresh clone, not on the machine where you set everything up. A setup that only works for its author is the failure this whole toolchain exists to prevent.
git config core.hooksPathprints.husky/_package.jsonhas"prepare": "husky"and it survives a freshnpm install.husky/pre-commitcontainsnpx lint-stagedand nothing project-wide- An intentionally misformatted file gets rewritten by Prettier during commit
- An unfixable ESLint error blocks the commit with a readable message
.gitattributespins.husky/**to LF line endings.eslintcacheand Prettier cache paths are git-ignoredtsc --noEmitruns inpre-pushor CI, never inpre-commit- CI sets
HUSKY=0and runsprettier --check, notprettier --write - Total pre-commit time on a typical commit is under two seconds
Frequently Asked Questions
What changed between Husky v8 and v9?
| Husky v8 | Husky v9 | |
|---|---|---|
| Initialize | npx husky install | npx husky init |
| Config file | .huskyrc / husky.config.js | No config file: hooks are plain shell scripts |
| Auto-install | "prepare": "husky install" | "prepare": "husky" (added automatically) |
| Hook format | JSON / JS config | Plain shell script in .husky/ |
How do I skip the pre-commit hook for an emergency commit?
Use the --no-verify flag:
git commit --no-verify -m "emergency fix"Why use lint-staged instead of running ESLint on the whole project?
Running ESLint on a large codebase before every commit gets slow: 10, 20, 30 seconds per commit. lint-staged scopes the run to only the files you've staged, keeping pre-commit checks under a second on most projects.
Should I use Prettier, ESLint, or both?
| ESLint | Prettier | |
|---|---|---|
| Purpose | Code quality (unused vars, type errors, logic bugs) | Formatting (indentation, quotes, line breaks) |
| Auto-fix | Partial (some rules only) | Always (formatting is deterministic) |
| Overlap | None | None |
Run ESLint first, then Prettier. Prettier's formatting pass is the final state committed.
Will Husky hooks run in CI?
By default, yes, but you usually don't want them to. In CI, run lint and type-check directly as explicit steps.
HUSKY=0 npm ciDoes this setup work with Yarn or pnpm?
Yes. Replace npm install --save-dev with yarn add --dev or pnpm add -D. The husky init command and lint-staged config are identical: only the package manager command changes.
Yarn Plug'n'Play is the one setup that needs care. Under PnP there is no node_modules/.bin, so a bare eslint in your lint-staged config will not resolve. Prefix the tasks with yarn so they run through Yarn's resolver instead.
{
"lint-staged": {
"*.{ts,tsx}": ["yarn eslint --fix", "yarn prettier --write"]
}
}My pre-commit hook is not running at all. How do I debug it?
Check whether Git knows about Husky before you touch anything else. On a working v9 install, git config core.hooksPath prints .husky/_.
git config core.hooksPath # expect: .husky/_
npm run prepare # reinstall if the above was empty
git commit --allow-empty -m "hook smoke test"Empty output means the prepare script never ran in this clone, which happens whenever someone installs with --ignore-scripts. If the config is correct but the hook still does nothing, check line endings (.husky/** must be LF) and the executable bit on .husky/pre-commit.
If it works in your terminal but not in a Git GUI, the GUI cannot find node. Add a ~/.config/husky/init.sh that sources your version manager, as described in the troubleshooting section above.
Where does Husky go in a monorepo?
At the repository root, always. Git hooks belong to the repository, and there is only one .git directory in a monorepo, so installing Husky inside a workspace package does nothing.
lint-staged is the part that can be distributed. It resolves the closest config file to each staged file, so every package can define its own tasks and a commit spanning two packages runs each package's rules against only its own files.
My pre-commit hook takes 15 seconds. How do I speed it up?
Something project-wide is running inside it. In practice it is almost always tsc --noEmit, a test suite, or type-aware ESLint rules pulling in the full TypeScript program on every commit.
| Belongs in pre-commit | Belongs in pre-push or CI | |
|---|---|---|
| Scope | Staged files only | Whole project |
| Typical commands | eslint --fix, prettier --write | tsc --noEmit, tests, build |
| Acceptable duration | Under 2 seconds | Seconds to minutes |
| Caching | --cache on both tools | CI-level dependency cache |
Add --cache to ESLint and Prettier, drop type-aware rules from the staged run, and move whole-project checks to pre-push. That combination usually brings a 15 second hook back under one second.
Should lint-staged touch .env files and other config?
No. Add .env* to .prettierignore. Prettier has no parser for the dotenv format, and any reformatting risks corrupting values where whitespace and quoting are significant.
node_modules
.next
out
coverage
.env*
package-lock.json
pnpm-lock.yamlLockfiles belong in the same list: they are generated, they are enormous, and reformatting them produces noisy diffs for no benefit. Quoting rules in .env files are subtle enough to deserve their own treatment, covered in the Next.js environment variables guide.
Husky v9, Prettier, and lint-staged take about five minutes to set up and save hours of back-and-forth over formatting in code review.
Keep the pre-commit hook fast: format and lint only staged files. Move tsc --noEmit to a pre-push hook where a slower check is acceptable.
Related Articles
How to Use Environment Variables in Next.js (Without Leaking Them to the Browser)
Learn how to use .env files in Next.js correctly. Understand NEXT_PUBLIC_, avoid common mistakes, and set variables in Vercel and Cloudflare.
npm Scripts You're Probably Not Using (But Should Be)
pre/post hooks, cross-env, npm-run-all, argument passing, and built-in variables: the npm script patterns developers Google one at a time, in one place.