Linting
Status: 🟩 COMPLETE Last updated: 2026-06-19 Plain-English tagline: An automated grammar-checker for your code — flags common mistakes, enforces style consistency, catches whole categories of bugs before runtime — and modern linters can fix most issues automatically.
In plain English
When you write English, a spell-checker underlines typos. A grammar-checker flags awkward sentences. They don’t write the essay for you, but they catch the small mistakes you’d otherwise ship.
Linting does the same thing for code. A linter is a program that reads your source files and flags problems:
- “You declared this variable and never used it”
- “This
ifcondition is always true” - “You used
==(loose) instead of===(strict) — probably a bug” - “This
useEffecthook is missing a dependency” - “You have an unreachable
returnafter thisthrow” - “This file has a mix of tabs and spaces”
- “This function might
return undefinedbut its signature says it always returns a number”
A linter doesn’t run your code. It reads it as text + AST (Abstract Syntax Tree — the parsed structure of the source) and applies rules to spot bad patterns. Many issues it catches would have been runtime bugs; many are just style smells. Either way, fixing them before the code reaches git push keeps the codebase clean.
The dominant linter in JavaScript/TypeScript-land is ESLint. Almost every modern JS project uses it. Newer alternatives (Biome, oxc) are emerging as faster, simpler successors, but ESLint is still the safe default in 2026.
Linting is paired naturally with formatting — a related but distinct tool. A linter checks for problems; a formatter rewrites your code to a consistent style. Prettier is the dominant formatter, often run alongside ESLint. The distinction:
- ESLint = “this code has potential bugs and style issues, here are the rules I checked against”
- Prettier = “I’m going to reformat this code to match the project’s style”
Modern stacks run BOTH on every save and every commit. The end result: code that looks the same regardless of who wrote it, with a class of bugs eliminated before they reach a test.
Why it matters
Three concrete reasons linting matters more than it sounds like:
-
It’s the cheapest quality layer. Type checking is free of writing extra code. Linting is free of writing extra code AND runs in milliseconds. For zero ongoing investment, it catches a meaningful percentage of real bugs.
-
Consistency reduces cognitive load. When every file in a codebase follows the same conventions — same import order, same brace placement, same quote style — your brain can focus on logic instead of decoding style differences.
-
AI tools generate cleaner code. When the project has linting + formatting set up, AI assistants (Claude Code, Copilot) tend to produce code that matches the project’s style automatically — because the lint rules act as live feedback.
The trade-off: linting can be over-applied. A 4000-rule config that fights you on every line creates resentment, not quality. The art is picking a tight set of rules that catch real problems without becoming bureaucratic.
What ESLint actually checks
A few categories of issues:
Likely bugs
no-unused-vars— declared but never usedno-undef— referencing variables that don’t existreact-hooks/exhaustive-deps— missing dependencies inuseEffectno-fallthrough— switch cases withoutbreakno-unreachable— code after areturn/throweqeqeq— using==instead of===react-hooks/rules-of-hooks— hooks called outside the right context
Code style
indentquotes— single or doublesemi— semicolons or notcomma-dangle— trailing commas
Best practices
no-console— no leftover debug logsprefer-const— useconstwhen not reassigningno-var— uselet/const, notvarprefer-template— template literals instead of string concatenation
TypeScript-specific (@typescript-eslint)
@typescript-eslint/no-explicit-any— discouragesany@typescript-eslint/no-unused-vars— TS-aware unused vars@typescript-eslint/no-floating-promises— unhandled promises@typescript-eslint/no-misused-promises—awaitissues
In a typical Next.js project, several hundred rules are configured. Most you never see — they fire silently in CI when something’s wrong.
A concrete example: catching a real bug
// Buggy code
function getUserName(user) {
if (user.name) {
return user.name;
} else if (user.email = "admin") { // ❌ assignment, not comparison
return "Admin";
}
return "Anonymous";
}The user.email = "admin" is = (assignment) not == (comparison). Every call to this function rewrites user.email to "admin" and then evaluates to "admin" (truthy), so it ALWAYS returns "Admin".
Without linting: ships, breaks production, takes an hour to debug.
With ESLint (no-cond-assign rule):
error Unexpected assignment within an 'else if' condition no-cond-assign
Caught at the moment you save. Fixed in 5 seconds.
This category of catch — “you almost certainly meant == but wrote =” — happens hundreds of times across a real codebase. Linting prevents every one.
Setting up ESLint in a Next.js project
Next.js ships with ESLint built in:
npx create-next-app@latest # Creates the project with ESLint pre-configuredThe relevant files:
.eslintrc.json(oreslint.config.mjsin newer versions using flat config) — the rules.eslintignore(orignoresin flat config) — files to skip
A modern Next.js project’s eslint.config.mjs:
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({ baseDirectory: __dirname });
export default [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
rules: {
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"no-console": ["warn", { allow: ["warn", "error"] }],
},
},
];What this does:
- Extends Next.js’s default config (which includes recommended TypeScript + React + accessibility rules)
- Allows unused params if they start with
_(a common convention for “I know this is unused”) - Warns on
console.logbut allowsconsole.warnandconsole.error
Run with:
npx eslint .
npx eslint . --fix # Auto-fix fixable issuesIn Next.js projects, npm run lint is wired up by default.
ESLint configurations to know
Several popular preset configs people extend from:
| Config | What it brings |
|---|---|
eslint:recommended | The bare ESLint built-in baseline |
plugin:@typescript-eslint/recommended | TypeScript-aware rules |
next/core-web-vitals | Next.js + Web Vitals best practices |
plugin:react/recommended | React rules |
plugin:react-hooks/recommended | Hook-specific rules (deps, rules-of-hooks) |
plugin:jsx-a11y/recommended | Accessibility checks for JSX |
airbnb / airbnb-typescript | Strict, opinionated style |
standard | Another opinionated style |
prettier | DISABLES rules that conflict with Prettier |
The pattern: extend one or more presets, then override specific rules to match your team’s preferences.
A note on prettier: when you use Prettier for formatting, add eslint-config-prettier to your extends list to disable ESLint’s formatting rules that would fight with Prettier.
Prettier — formatting, not linting
Prettier doesn’t check for bugs. It enforces formatting. You write:
const x={a: 1,b:2, c :3}Prettier rewrites it to:
const x = { a: 1, b: 2, c: 3 };The point isn’t that Prettier’s style is “best.” It’s that Prettier eliminates style debates. The team picks Prettier’s defaults (or tunes a handful), commits a .prettierrc, and never argues about whether trailing commas are good ever again.
// .prettierrc
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2
}Run with:
npx prettier --write . # Reformat all files
npx prettier --check . # Just check, fail if any file would changeIn VS Code, install the Prettier extension and enable “Format on Save.” Every save reformats the current file.
Running lint + format on every commit (Husky + lint-staged)
To make linting reflexive, run it automatically on every commit:
npm install -D husky lint-staged
npx husky initThen .husky/pre-commit:
npx lint-stagedAnd package.json:
{
"lint-staged": {
"*.{ts,tsx,js,jsx}": [
"eslint --fix",
"prettier --write"
],
"*.{md,json,yml}": [
"prettier --write"
]
}
}Now: on every git commit, Husky runs lint-staged, which runs ESLint + Prettier only on the staged files. If anything can’t be fixed automatically, the commit aborts.
The result: NO unformatted code ever reaches the repo. NO obvious lint errors ever sneak past.
For Bible Quest-style projects, this is a 5-minute setup that pays dividends every working day.
Linting in CI
Even with pre-commit hooks, run lint in CI as a safety net:
# .github/workflows/ci.yml
- run: npm run lint
- run: npx prettier --check .
- run: npx tsc --noEmitThree lines, three checks: lint errors fail the PR; unformatted files fail the PR; type errors fail the PR. Cheap and effective.
Auto-fixing — the underrated superpower
Many lint rules have automated fixes. Run:
npx eslint . --fixESLint applies every safe fix it can. Most projects can drop hundreds of issues from a fresh codebase in seconds.
VS Code can run auto-fix on save:
// .vscode/settings.json
{
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"editor.defaultFormatter": "esbenp.prettier-vscode"
}Every save: ESLint fixes what it can, Prettier reformats. By the time you commit, the code is already clean. You barely think about it.
The newer alternatives (2026)
| Tool | Notes |
|---|---|
| Biome | Rust-based, all-in-one (lint + format), 10–100× faster than ESLint + Prettier. Drop-in for many projects. Still maturing ecosystem-wise. |
| oxc | Oxc Linter is the fastest of all, written in Rust. Subset of ESLint rules. Designed to eventually replace ESLint in large codebases. |
| deno lint / deno fmt | Built into Deno. Excellent if you’re already in the Deno world. |
| Bun’s built-in linter (preview) | Bun is exploring its own bundled linter. Not yet stable in 2026. |
The trade-off with Rust-based linters: dramatic speed, but smaller rule ecosystems. ESLint has 1000s of community rules; Biome and oxc have hundreds. For most projects, the speedup is worth it. For projects relying on niche rules, stick with ESLint.
For new projects in 2026: ESLint + Prettier is still the safe default. Biome is a serious alternative if you want speed and simpler config. Pick based on tolerance for newer tooling.
Common gotchas
-
A 4000-rule config is hostile. Start lean, add rules only when you see real problems. A handful of well-chosen rules is more valuable than a kitchen-sink config nobody understands.
-
// eslint-disable-next-lineis sometimes right. Genuine exceptions exist. But when you disable a rule, leave a comment explaining why. A bare// eslint-disable-next-lineis a code smell. -
Disabled rules in
.eslintrcshould also be documented. A teammate looks at the config and wonders “why isno-unused-varsoff?” — leave a comment. -
Prettier and ESLint can fight. Without
eslint-config-prettier, ESLint’s formatting rules conflict with Prettier’s choices. The fix: installeslint-config-prettierand add it LAST in your extends list. -
--fixcan rewrite code subtly. Auto-fix changes can occasionally alter behavior (e.g. a fix that re-orders operations). Review fixes before committing on large codebases. -
Lint warnings vs errors. A warning shows up in the console but doesn’t fail the build (by default). Many projects miss that an entire category of “warnings” has piled up. Set rules to
errorfor things you actually care about; treat warnings as “to fix later” only if you’ll actually fix them. -
Type-aware lint rules need the tsconfig path. Some
@typescript-eslintrules requireparserOptions.projectto point at yourtsconfig.json. Without it, they silently skip. -
Auto-import sorting can change git blames. A rule like
import/orderthat reorders imports rewrites large portions of files. Diff history changes;git blamebecomes less useful. Trade-off; many teams accept it. -
Lint can be slow on monorepos. ESLint over 50,000 files takes 30+ seconds. Use caching (
--cache), parallel runners, or migrate to Biome/oxc. -
.eslintignoredoesn’t auto-update. If you add a new generated folder (/dist,/.next), remember to add it to ignores. Otherwise lint runs over generated code and produces nonsense errors. -
Husky pre-commit hooks can be bypassed. A teammate running
git commit --no-verifyskips them. Lint in CI as a backstop. -
Editor lint feedback != CI lint feedback. Your editor might use an older version of the config; CI uses the committed version. Lock dependencies; run the same command locally and in CI.
-
AI-generated code can drift from lint rules. If your AI assistant generates code that doesn’t match your conventions, either accept the warnings or instruct the AI about your conventions in CLAUDE.md / system prompts.
-
Strict rules + legacy code = pain. If you adopt strict rules in a large existing codebase, expect hundreds of errors. Use
--max-warningsand ratchet down over time, or use lint-staged so old code is grandfathered. -
no-explicit-anycan become a religion. Sometimesanyis genuinely the right escape hatch (interop with untyped APIs). Use sparingly with a comment, and don’t let the rule become bureaucratic. -
@ts-ignoreandeslint-disableclusters in one file signal a hot mess. Investigate root cause; refactor; don’t paper over with directives. -
Flat config (new) vs legacy
.eslintrc(old). ESLint 9+ defaults to flat config (eslint.config.mjs). Many tutorials still show legacy config. Modern Next.js (15+) uses flat config. Don’t mix them in one project. -
@ts-checkcomments are an alternative to TypeScript. For non-TS projects, you can opt into type-checking via JSDoc +// @ts-check. ESLint’sno-undefand friends are nice but TS-via-JSDoc is stronger. -
Lint rules can mask test failures. A linter that flags unused variables can hide a test that was meant to use them. Read lint output; don’t auto-
--fixblindly on someone else’s code. -
react-hooks/exhaustive-depsis sometimes wrong. It can demand dependencies that you legitimately don’t want. Disable per-line with a comment explaining; never globally. -
The “rules” presets evolve.
next/core-web-vitalsadds new rules in major Next.js versions. An upgrade can suddenly add 50 warnings. Read changelogs. -
A project with no lint setup is a red flag. When joining any codebase, check for ESLint + Prettier configs. Their absence often signals deeper hygiene issues.
-
Comments survive Prettier; some
// prettier-ignoredirectives don’t. A// prettier-ignoreabove a specific construct tells Prettier to leave that ONE line alone. Useful for hand-formatted tables/arrays. -
Don’t mix tabs and spaces. It used to be a religious war; now Prettier picks one and enforces it. Just commit a
.prettierrcand move on. -
EditorConfig is a thing too. A
.editorconfigfile at the project root tells editors how to handle indents, line endings, charset. Add it (10 lines) for consistency across editors that don’t all use Prettier.
When linting hurts more than helps
Edge cases where linting is more bureaucratic than useful:
- One-file scripts that run once and get deleted
- Generated code (always ignore; never lint)
- Vendored copies of third-party libraries
The rule of thumb: lint what you maintain; skip what you don’t.
See also
- Why test? 🟩 — the broader quality story
- Unit tests đźź©
- Integration tests đźź©
- End-to-end (E2E) tests đźź©
- Type checking 🟥 — the strongest static check
- Code review 🟥 — what humans + AI catch beyond lint
- TypeScript 🟩 —
@typescript-eslintis essential alongsidetsc - JavaScript 🟩 — what ESLint was originally designed for
- Next.js 🟩 🟦 — ships with ESLint pre-configured
- CD 🟩 — where lint runs as a gate
- Git 🟩 — Husky pre-commit hooks use Git hooks
- Glossary: Lint, ESLint, Prettier, AST
Sources
- ESLint docs — canonical reference
- Prettier docs
- typescript-eslint — TS-aware lint rules
- Biome docs — the modern Rust-based alternative
- eslint-plugin-react
- Next.js ESLint config
- Husky docs
- lint-staged docs