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 if condition is always true”
  • “You used == (loose) instead of === (strict) — probably a bug”
  • “This useEffect hook is missing a dependency”
  • “You have an unreachable return after this throw”
  • “This file has a mix of tabs and spaces”
  • “This function might return undefined but 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:

  1. 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.

  2. 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.

  3. 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 used
  • no-undef — referencing variables that don’t exist
  • react-hooks/exhaustive-deps — missing dependencies in useEffect
  • no-fallthrough — switch cases without break
  • no-unreachable — code after a return/throw
  • eqeqeq — using == instead of ===
  • react-hooks/rules-of-hooks — hooks called outside the right context

Code style

  • indent
  • quotes — single or double
  • semi — semicolons or not
  • comma-dangle — trailing commas

Best practices

  • no-console — no leftover debug logs
  • prefer-const — use const when not reassigning
  • no-var — use let/const, not var
  • prefer-template — template literals instead of string concatenation

TypeScript-specific (@typescript-eslint)

  • @typescript-eslint/no-explicit-any — discourages any
  • @typescript-eslint/no-unused-vars — TS-aware unused vars
  • @typescript-eslint/no-floating-promises — unhandled promises
  • @typescript-eslint/no-misused-promises — await issues

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-configured

The relevant files:

  • .eslintrc.json (or eslint.config.mjs in newer versions using flat config) — the rules
  • .eslintignore (or ignores in 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.log but allows console.warn and console.error

Run with:

npx eslint .
npx eslint . --fix       # Auto-fix fixable issues

In Next.js projects, npm run lint is wired up by default.


ESLint configurations to know

Several popular preset configs people extend from:

ConfigWhat it brings
eslint:recommendedThe bare ESLint built-in baseline
plugin:@typescript-eslint/recommendedTypeScript-aware rules
next/core-web-vitalsNext.js + Web Vitals best practices
plugin:react/recommendedReact rules
plugin:react-hooks/recommendedHook-specific rules (deps, rules-of-hooks)
plugin:jsx-a11y/recommendedAccessibility checks for JSX
airbnb / airbnb-typescriptStrict, opinionated style
standardAnother opinionated style
prettierDISABLES 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 change

In 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 init

Then .husky/pre-commit:

npx lint-staged

And 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 --noEmit

Three 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 . --fix

ESLint 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)

ToolNotes
BiomeRust-based, all-in-one (lint + format), 10–100× faster than ESLint + Prettier. Drop-in for many projects. Still maturing ecosystem-wise.
oxcOxc Linter is the fastest of all, written in Rust. Subset of ESLint rules. Designed to eventually replace ESLint in large codebases.
deno lint / deno fmtBuilt 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-line is sometimes right. Genuine exceptions exist. But when you disable a rule, leave a comment explaining why. A bare // eslint-disable-next-line is a code smell.

  • Disabled rules in .eslintrc should also be documented. A teammate looks at the config and wonders “why is no-unused-vars off?” — leave a comment.

  • Prettier and ESLint can fight. Without eslint-config-prettier, ESLint’s formatting rules conflict with Prettier’s choices. The fix: install eslint-config-prettier and add it LAST in your extends list.

  • --fix can 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 error for 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-eslint rules require parserOptions.project to point at your tsconfig.json. Without it, they silently skip.

  • Auto-import sorting can change git blames. A rule like import/order that reorders imports rewrites large portions of files. Diff history changes; git blame becomes 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.

  • .eslintignore doesn’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-verify skips 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-warnings and ratchet down over time, or use lint-staged so old code is grandfathered.

  • no-explicit-any can become a religion. Sometimes any is genuinely the right escape hatch (interop with untyped APIs). Use sparingly with a comment, and don’t let the rule become bureaucratic.

  • @ts-ignore and eslint-disable clusters 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-check comments are an alternative to TypeScript. For non-TS projects, you can opt into type-checking via JSDoc + // @ts-check. ESLint’s no-undef and 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---fix blindly on someone else’s code.

  • react-hooks/exhaustive-deps is 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-vitals adds 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-ignore directives don’t. A // prettier-ignore above 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 .prettierrc and move on.

  • EditorConfig is a thing too. A .editorconfig file 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


Sources