Writing good code is only half the equation. The other half is the infrastructure around it — the tools that catch problems before they reach production, enforce consistency across a team, and automate the repetitive work so developers can focus on what matters.
This guide walks through the essential tooling stack for a modern web project: linting, formatting, pre-commit hooks, and CI/CD pipelines.
Why Tooling Matters
Without tooling, even experienced teams end up with:
- Inconsistent code style across contributors
- Bugs that a linter would have caught in seconds
- Broken builds discovered only after merging to main
- Time wasted on style debates in code review
Good tooling makes these problems disappear automatically.
1. ESLint — Catching Problems Before Runtime
ESLint statically analyses your code to find bugs, enforce best practices, and flag anti-patterns — before you run a single line.
Setup
| 1 | npm install --save-dev eslint @eslint/js |
| 2 | npx eslint --init |
For a Next.js + TypeScript project, use the official config:
| 1 | npm install --save-dev eslint-config-next |
eslint.config.js (Flat Config format)
| 1 | import js from '@eslint/js'; |
| 2 | import nextPlugin from '@next/eslint-plugin-next'; |
| 3 | import tsPlugin from '@typescript-eslint/eslint-plugin'; |
| 4 | import tsParser from '@typescript-eslint/parser'; |
| 5 | |
| 6 | export default [ |
| 7 | js.configs.recommended, |
| 8 | { |
| 9 | files: ['**/*.{ts,tsx}'], |
| 10 | plugins: { |
| 11 | '@typescript-eslint': tsPlugin, |
| 12 | '@next/next': nextPlugin, |
| 13 | }, |
| 14 | languageOptions: { |
| 15 | parser: tsParser, |
| 16 | parserOptions: { |
| 17 | project: './tsconfig.json', |
| 18 | }, |
| 19 | }, |
| 20 | rules: { |
| 21 | |
| 22 | 'react-hooks/rules-of-hooks': 'error', |
| 23 | 'react-hooks/exhaustive-deps': 'warn', |
| 24 | |
| 25 | |
| 26 | '@typescript-eslint/no-unused-vars': 'error', |
| 27 | '@typescript-eslint/no-explicit-any': 'warn', |
| 28 | '@typescript-eslint/prefer-nullish-coalescing': 'error', |
| 29 | |
| 30 | |
| 31 | '@next/next/no-img-element': 'error', |
| 32 | '@next/next/no-html-link-for-pages': 'error', |
| 33 | }, |
| 34 | }, |
| 35 | ]; |
Add to package.json
| 1 | { |
| 2 | "scripts": { |
| 3 | "lint": "eslint . --ext .ts,.tsx", |
| 4 | "lint:fix": "eslint . --ext .ts,.tsx --fix" |
| 5 | } |
| 6 | } |
Key Rules to Enable
| Rule | Why |
no-unused-vars | Dead code causes confusion and bloat |
react-hooks/exhaustive-deps | Missing deps in useEffect cause stale closures |
@next/next/no-img-element | Forces use of next/image for performance |
prefer-const | Signals immutability intent clearly |
no-console (warn) | Prevents accidental logs in production |
2. Prettier — Consistent Formatting, Zero Arguments
Prettier is an opinionated code formatter. It reformats your code automatically so the whole team writes in the same style — no more debates about tabs vs spaces, semicolons, or quote style.
Setup
| 1 | npm install --save-dev prettier eslint-config-prettier |
eslint-config-prettier disables ESLint rules that would conflict with Prettier.
.prettierrc
| 1 | { |
| 2 | "semi": true, |
| 3 | "singleQuote": true, |
| 4 | "trailingComma": "es5", |
| 5 | "tabWidth": 2, |
| 6 | "printWidth": 100, |
| 7 | "plugins": ["prettier-plugin-tailwindcss"] |
| 8 | } |
💡 prettier-plugin-tailwindcss automatically sorts Tailwind class names — great if you're using Tailwind CSS.
.prettierignore
| 1 | .next |
| 2 | node_modules |
| 3 | public |
| 4 | *.min.js |
Add to package.json
| 1 | { |
| 2 | "scripts": { |
| 3 | "format": "prettier --write .", |
| 4 | "format:check": "prettier --check ." |
| 5 | } |
| 6 | } |
ESLint + Prettier together
Add prettier to the end of your ESLint extends array so it always wins on formatting conflicts:
| 1 | { |
| 2 | "extends": ["next/core-web-vitals", "prettier"] |
| 3 | } |
3. Husky + lint-staged — Pre-Commit Hooks
Husky runs scripts before git commits. Combined with lint-staged (which only lints files you've actually changed), this ensures nothing broken ever enters version control.
Setup
| 1 | npm install --save-dev husky lint-staged |
| 2 | npx husky init |
.husky/pre-commit
| 1 | #!/bin/sh |
| 2 | npx lint-staged |
lint-staged config in package.json
| 1 | { |
| 2 | "lint-staged": { |
| 3 | "*.{ts,tsx}": [ |
| 4 | "eslint --fix", |
| 5 | "prettier --write" |
| 6 | ], |
| 7 | "*.{js,json,css,md}": [ |
| 8 | "prettier --write" |
| 9 | ] |
| 10 | } |
| 11 | } |
Now whenever a developer tries to commit, their changed files are automatically linted and formatted. If ESLint finds an unfixable error, the commit is blocked until it's resolved.
4. TypeScript's tsc — Type Checking in CI
ESLint and Prettier catch style and some logic errors, but TypeScript's compiler catches type errors. Run it as a separate check — don't rely on the editor alone.
| 1 | { |
| 2 | "scripts": { |
| 3 | "type-check": "tsc --noEmit" |
| 4 | } |
| 5 | } |
The --noEmit flag runs the type checker without producing any output files — perfect for CI.
5. CI/CD with GitHub Actions
All the local tooling in the world is useless if it's easy to bypass. A CI pipeline enforces it for everyone on every pull request.
.github/workflows/ci.yml
| 1 | name: CI |
| 2 | |
| 3 | on: |
| 4 | push: |
| 5 | branches: [main] |
| 6 | pull_request: |
| 7 | branches: [main] |
| 8 | |
| 9 | jobs: |
| 10 | quality: |
| 11 | name: Code Quality |
| 12 | runs-on: ubuntu-latest |
| 13 | |
| 14 | steps: |
| 15 | - name: Checkout code |
| 16 | uses: actions/checkout@v4 |
| 17 | |
| 18 | - name: Setup Node.js |
| 19 | uses: actions/setup-node@v4 |
| 20 | with: |
| 21 | node-version: '20' |
| 22 | cache: 'npm' |
| 23 | |
| 24 | - name: Install dependencies |
| 25 | run: npm ci |
| 26 | |
| 27 | - name: Type check |
| 28 | run: npm run type-check |
| 29 | |
| 30 | - name: Lint |
| 31 | run: npm run lint |
| 32 | |
| 33 | - name: Format check |
| 34 | run: npm run format:check |
| 35 | |
| 36 | - name: Build |
| 37 | run: npm run build |
This workflow runs on every push and PR to main. A PR can't be merged if any step fails.
Caching Dependencies
For faster runs, cache your node_modules:
| 1 | - name: Cache node_modules |
| 2 | uses: actions/cache@v4 |
| 3 | with: |
| 4 | path: ~/.npm |
| 5 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} |
| 6 | restore-keys: | |
| 7 | ${{ runner.os }}-node- |
Deployment Step (Vercel)
| 1 | deploy: |
| 2 | name: Deploy to Vercel |
| 3 | runs-on: ubuntu-latest |
| 4 | needs: quality |
| 5 | if: github.ref == 'refs/heads/main' |
| 6 | |
| 7 | steps: |
| 8 | - uses: actions/checkout@v4 |
| 9 | - name: Deploy |
| 10 | run: npx vercel --prod --token=${{ secrets.VERCEL_TOKEN }} |
6. .editorconfig — Cross-Editor Consistency
Even with Prettier, different editors can apply different defaults before Prettier runs. An .editorconfig file sets a baseline that most editors respect:
| 1 | root = true |
| 2 | |
| 3 | [*] |
| 4 | indent_style = space |
| 5 | indent_size = 2 |
| 6 | end_of_line = lf |
| 7 | charset = utf-8 |
| 8 | trim_trailing_whitespace = true |
| 9 | insert_final_newline = true |
| 10 | |
| 11 | [*.md] |
| 12 | trim_trailing_whitespace = false |
Putting It All Together
Here's the complete package.json scripts block for a project using all of the above:
| 1 | { |
| 2 | "scripts": { |
| 3 | "dev": "next dev", |
| 4 | "build": "next build", |
| 5 | "start": "next start", |
| 6 | "lint": "eslint . --ext .ts,.tsx", |
| 7 | "lint:fix": "eslint . --ext .ts,.tsx --fix", |
| 8 | "format": "prettier --write .", |
| 9 | "format:check": "prettier --check .", |
| 10 | "type-check": "tsc --noEmit" |
| 11 | } |
| 12 | } |
And the development workflow:
| 1 | Code → Save → Editor auto-formats (Prettier) |
| 2 | ↓ |
| 3 | git commit → Husky runs lint-staged |
| 4 | → ESLint fixes + Prettier formats changed files |
| 5 | → Blocks commit if errors remain |
| 6 | ↓ |
| 7 | Push → GitHub Actions runs |
| 8 | → type-check + lint + format:check + build |
| 9 | → Blocks PR merge if any step fails |
| 10 | ↓ |
| 11 | Merge to main → Auto-deploy to Vercel |
Tooling Checklist
- ✅ ESLint configured with TypeScript and Next.js plugins
- ✅ Prettier set up with
eslint-config-prettier to avoid conflicts
- ✅ Husky +
lint-staged running on pre-commit
- ✅
tsc --noEmit in your CI pipeline
- ✅ GitHub Actions workflow running on every PR
- ✅ Build step in CI — catch broken builds before merging
- ✅
.editorconfig for cross-editor baseline consistency
- ✅
npm ci in CI (not npm install) for reproducible installs