Linting & Formatting
Linting and formatting are static analysis tools that enforce code quality, consistency, and best practices without executing your code. ESLint catches potential bugs and style issues, while Prettier ensures consistent formatting across your codebase.
This guide covers ESLint configuration (including the new flat config), rules and plugins, Prettier integration, pre-commit hooks with husky and lint-staged, editor integration (VS Code), and custom rule creation.
ESLint supports two configuration formats: the traditional .eslintrc (JSON/YAML/JS) and the modern flat config (eslint.config.js) introduced in ESLint v9.
Flat Config (ESLint v9+)
| 1 | // eslint.config.js — Modern flat config (ESLint v9+) |
| 2 | import js from "@eslint/js"; |
| 3 | import tseslint from "typescript-eslint"; |
| 4 | import react from "eslint-plugin-react"; |
| 5 | import reactHooks from "eslint-plugin-react-hooks"; |
| 6 | import jsxA11y from "eslint-plugin-jsx-a11y"; |
| 7 | import globals from "globals"; |
| 8 | |
| 9 | export default [ |
| 10 | // Global ignores |
| 11 | { |
| 12 | ignores: [ |
| 13 | "dist/**", |
| 14 | "build/**", |
| 15 | ".next/**", |
| 16 | "node_modules/**", |
| 17 | ], |
| 18 | }, |
| 19 | |
| 20 | // Base recommended rules |
| 21 | js.configs.recommended, |
| 22 | |
| 23 | // TypeScript rules |
| 24 | ...tseslint.configs.recommended, |
| 25 | |
| 26 | // React rules |
| 27 | { |
| 28 | files: ["**/*.{jsx,tsx}"], |
| 29 | plugins: { |
| 30 | react, |
| 31 | "react-hooks": reactHooks, |
| 32 | "jsx-a11y": jsxA11y, |
| 33 | }, |
| 34 | languageOptions: { |
| 35 | globals: { |
| 36 | ...globals.browser, |
| 37 | ...globals.es2022, |
| 38 | }, |
| 39 | parserOptions: { |
| 40 | ecmaFeatures: { jsx: true }, |
| 41 | }, |
| 42 | }, |
| 43 | rules: { |
| 44 | ...react.configs.recommended.rules, |
| 45 | ...reactHooks.configs.recommended.rules, |
| 46 | ...jsxA11y.configs.recommended.rules, |
| 47 | |
| 48 | "react/react-in-jsx-scope": "off", |
| 49 | "react/prop-types": "off", |
| 50 | "react/jsx-no-target-blank": "error", |
| 51 | |
| 52 | "react-hooks/rules-of-hooks": "error", |
| 53 | "react-hooks/exhaustive-deps": "warn", |
| 54 | |
| 55 | "jsx-a11y/anchor-is-valid": [ |
| 56 | "error", |
| 57 | { components: ["Link"], specialLink: ["to"] }, |
| 58 | ], |
| 59 | }, |
| 60 | settings: { |
| 61 | react: { version: "detect" }, |
| 62 | }, |
| 63 | }, |
| 64 | |
| 65 | // Node files (no browser globals) |
| 66 | { |
| 67 | files: ["**/*.config.{js,ts}", "scripts/**"], |
| 68 | languageOptions: { |
| 69 | globals: { |
| 70 | ...globals.node, |
| 71 | }, |
| 72 | }, |
| 73 | rules: { |
| 74 | "no-console": "off", |
| 75 | }, |
| 76 | }, |
| 77 | ]; |
Legacy .eslintrc Format
| 1 | // .eslintrc.cjs — Traditional format (ESLint v8 and below) |
| 2 | module.exports = { |
| 3 | root: true, |
| 4 | env: { |
| 5 | browser: true, |
| 6 | es2022: true, |
| 7 | node: true, |
| 8 | }, |
| 9 | extends: [ |
| 10 | "eslint:recommended", |
| 11 | "plugin:@typescript-eslint/recommended", |
| 12 | "plugin:react/recommended", |
| 13 | "plugin:react-hooks/recommended", |
| 14 | "plugin:jsx-a11y/recommended", |
| 15 | "prettier", // Must be last to disable conflicting rules |
| 16 | ], |
| 17 | parser: "@typescript-eslint/parser", |
| 18 | parserOptions: { |
| 19 | ecmaVersion: "latest", |
| 20 | sourceType: "module", |
| 21 | ecmaFeatures: { jsx: true }, |
| 22 | }, |
| 23 | plugins: ["@typescript-eslint", "react", "react-hooks", "jsx-a11y"], |
| 24 | rules: { |
| 25 | "react/react-in-jsx-scope": "off", |
| 26 | "react/prop-types": "off", |
| 27 | "@typescript-eslint/no-unused-vars": [ |
| 28 | "warn", |
| 29 | { argsIgnorePattern: "^_" }, |
| 30 | ], |
| 31 | "no-console": ["warn", { allow: ["warn", "error"] }], |
| 32 | }, |
| 33 | settings: { |
| 34 | react: { version: "detect" }, |
| 35 | }, |
| 36 | ignorePatterns: ["dist", "build", ".next", "node_modules"], |
| 37 | }; |
info
ESLint rules are categorized by severity: off (0), warn (1), and error (2). Plugins extend ESLint with context-specific rules.
| Plugin | Purpose | Key Rules |
|---|---|---|
| @typescript-eslint | TypeScript-specific linting | no-unused-vars, no-explicit-any, strict-boolean-expressions |
| eslint-plugin-react | React best practices | jsx-no-target-blank, no-array-index-key, button-has-type |
| eslint-plugin-react-hooks | Hooks correctness | rules-of-hooks, exhaustive-deps |
| eslint-plugin-jsx-a11y | Accessibility checks | alt-text, anchor-is-valid, click-events-have-key-events |
| eslint-plugin-import | Import/export validation | no-unused-modules, order, no-cycle |
| eslint-plugin-prettier | Runs Prettier as ESLint rule | prettier/prettier |
| eslint-plugin-simple-import-sort | Import sorting | sort-imports |
| eslint-plugin-tailwindcss | Tailwind class ordering | classnames-order, no-custom-classname |
| eslint-plugin-testing-library | RTL best practices | await-async-queries, no-wait-for-snapshot |
| eslint-plugin-jest-dom | jest-dom matcher usage | prefer-to-have-text-content, prefer-to-be-visible |
| 1 | // Common rules configuration |
| 2 | |
| 3 | const commonRules = { |
| 4 | // TypeScript |
| 5 | "@typescript-eslint/no-unused-vars": [ |
| 6 | "error", |
| 7 | { argsIgnorePattern: "^_", destructuredArrayIgnorePattern: "^_" }, |
| 8 | ], |
| 9 | "@typescript-eslint/no-explicit-any": "warn", |
| 10 | "@typescript-eslint/consistent-type-imports": "error", |
| 11 | "@typescript-eslint/no-import-type-side-effects": "error", |
| 12 | |
| 13 | // React |
| 14 | "react/jsx-no-target-blank": ["error", { allowReferrer: true }], |
| 15 | "react/button-has-type": "error", |
| 16 | "react/no-array-index-key": "warn", |
| 17 | "react/self-closing-comp": "error", |
| 18 | |
| 19 | // Hooks |
| 20 | "react-hooks/rules-of-hooks": "error", |
| 21 | "react-hooks/exhaustive-deps": "warn", |
| 22 | |
| 23 | // Accessibility |
| 24 | "jsx-a11y/alt-text": "error", |
| 25 | "jsx-a11y/anchor-is-valid": "error", |
| 26 | "jsx-a11y/click-events-have-key-events": "warn", |
| 27 | "jsx-a11y/no-autofocus": "warn", |
| 28 | |
| 29 | // General |
| 30 | "no-console": ["warn", { allow: ["warn", "error"] }], |
| 31 | "prefer-const": "error", |
| 32 | "no-var": "error", |
| 33 | "eqeqeq": ["error", "always"], |
| 34 | "curly": ["error", "all"], |
| 35 | "no-throw-literal": "error", |
| 36 | "prefer-template": "warn", |
| 37 | }; |
best practice
Prettier is an opinionated code formatter that enforces consistent style (spacing, quotes, semicolons, line wrapping). It integrates with ESLint via eslint-config-prettier (disables conflicting ESLint rules) and optionally eslint-plugin-prettier (runs Prettier as a rule).
| 1 | { |
| 2 | "semi": true, |
| 3 | "singleQuote": false, |
| 4 | "tabWidth": 2, |
| 5 | "trailingComma": "all", |
| 6 | "printWidth": 80, |
| 7 | "bracketSpacing": true, |
| 8 | "arrowParens": "always", |
| 9 | "endOfLine": "lf", |
| 10 | "quoteProps": "as-needed", |
| 11 | "jsxSingleQuote": false, |
| 12 | "bracketSameLine": false, |
| 13 | "embeddedLanguageFormatting": "auto", |
| 14 | "htmlWhitespaceSensitivity": "css", |
| 15 | "proseWrap": "preserve" |
| 16 | } |
| 1 | // prettier.config.js — Alternative format |
| 2 | export default { |
| 3 | semi: true, |
| 4 | singleQuote: false, |
| 5 | tabWidth: 2, |
| 6 | trailingComma: "all", |
| 7 | printWidth: 80, |
| 8 | plugins: [ |
| 9 | "prettier-plugin-tailwindcss", // Sorts Tailwind classes |
| 10 | "prettier-plugin-organize-imports", // Organizes imports |
| 11 | ], |
| 12 | }; |
| 13 | |
| 14 | // .prettierignore — Files to skip |
| 15 | // build/ |
| 16 | // dist/ |
| 17 | // .next/ |
| 18 | // node_modules/ |
| 19 | // package-lock.json |
| 20 | // coverage/ |
| 21 | // *.generated.* |
| 1 | # Format all files |
| 2 | npx prettier --write . |
| 3 | |
| 4 | # Check formatting (CI mode) |
| 5 | npx prettier --check . |
| 6 | |
| 7 | # ESLint + Prettier together |
| 8 | npx eslint . --fix |
| 9 | npx prettier --write . |
| 10 | |
| 11 | # Or with eslint-plugin-prettier: |
| 12 | npx eslint . --fix # Prettier runs automatically |
warning
Husky manages Git hooks, and lint-staged runs linters only on staged files. Together they ensure every commit meets quality standards without slowing down the entire project.
| 1 | # Install husky |
| 2 | npm install -D husky |
| 3 | npx husky init |
| 4 | |
| 5 | # Install lint-staged |
| 6 | npm install -D lint-staged |
| 7 | |
| 8 | # .husky/pre-commit — Created by husky init |
| 9 | npx lint-staged |
| 10 | |
| 11 | # Configure lint-staged in package.json |
| 1 | // package.json — lint-staged configuration |
| 2 | { |
| 3 | "lint-staged": { |
| 4 | "*.{ts,tsx,js,jsx}": [ |
| 5 | "eslint --fix", |
| 6 | "prettier --write" |
| 7 | ], |
| 8 | "*.{json,md,yaml,yml}": [ |
| 9 | "prettier --write" |
| 10 | ], |
| 11 | "*.css": [ |
| 12 | "prettier --write" |
| 13 | ] |
| 14 | } |
| 15 | } |
| 16 | |
| 17 | // For monorepos, you can scope by directory: |
| 18 | // { |
| 19 | // "lint-staged": { |
| 20 | // "packages/web/**/*.{ts,tsx}": ["eslint --fix", "prettier --write"], |
| 21 | // "packages/api/**/*.ts": ["eslint --fix", "prettier --write"] |
| 22 | // } |
| 23 | // } |
| 1 | # Alternative: lint-staged config file |
| 2 | # .lintstagedrc.js |
| 3 | module.exports = { |
| 4 | "*.{ts,tsx,js,jsx}": [ |
| 5 | "eslint --fix", |
| 6 | "prettier --write", |
| 7 | ], |
| 8 | "*.{json,md,yaml,yml,css}": [ |
| 9 | "prettier --write", |
| 10 | ], |
| 11 | }; |
| 12 | |
| 13 | # Run only on staged files (dry-run to see what would run) |
| 14 | npx lint-staged --dry |
| 15 | |
| 16 | # Bypass hooks (use sparingly!) |
| 17 | git commit --no-verify |
pro tip
Configure VS Code to format on save, show lint errors inline, and auto-fix on save. This gives instant feedback without running CLI commands.
| 1 | // .vscode/settings.json |
| 2 | { |
| 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", |
| 4 | "editor.formatOnSave": true, |
| 5 | "editor.codeActionsOnSave": { |
| 6 | "source.fixAll.eslint": "explicit", |
| 7 | "source.organizeImports": "explicit" |
| 8 | }, |
| 9 | "eslint.validate": [ |
| 10 | "javascript", |
| 11 | "javascriptreact", |
| 12 | "typescript", |
| 13 | "typescriptreact" |
| 14 | ], |
| 15 | "eslint.useFlatConfig": true, |
| 16 | "typescript.preferences.importModuleSpecifier": "shortest", |
| 17 | "typescript.updateImportsOnFileMove.enabled": "always", |
| 18 | "css.lint.unknownAtRules": "ignore", |
| 19 | "files.associations": { |
| 20 | "*.css": "tailwindcss" |
| 21 | } |
| 22 | } |
| 23 | |
| 24 | // Extensions to install (in .vscode/extensions.json): |
| 25 | { |
| 26 | "recommendations": [ |
| 27 | "dbaeumer.vscode-eslint", |
| 28 | "esbenp.prettier-vscode", |
| 29 | "bradlc.vscode-tailwindcss" |
| 30 | ] |
| 31 | } |
info
TypeScript and React have specific linting needs. The typescript-eslint project provides TypeScript-aware rules, while React plugins cover JSX correctness and hook rules.
| Rule | Severity | Description |
|---|---|---|
| @typescript-eslint/no-explicit-any | warn | Avoid any — use unknown or specific types |
| @typescript-eslint/strict-boolean-expressions | warn | Require strict boolean checks (no truthy/falsy) |
| @typescript-eslint/consistent-type-imports | error | Use import type for type-only imports |
| react/no-array-index-key | warn | Avoid array index as key prop |
| react/button-has-type | error | Buttons must have explicit type attribute |
| react/jsx-no-leaked-render | error | Prevent accidental rendering of falsy values (0, NaN) |
| react-hooks/exhaustive-deps | warn | Verify hook dependency arrays |
| 1 | // Before linting — problematic patterns |
| 2 | function UserProfile({ user }: { user: any }) { |
| 3 | // ^^^ @typescript-eslint/no-explicit-any |
| 4 | |
| 5 | const [items, setItems] = useState([]); |
| 6 | |
| 7 | useEffect(() => { |
| 8 | fetchItems().then(setItems); |
| 9 | }, []); |
| 10 | // ^^^ react-hooks/exhaustive-deps warning |
| 11 | |
| 12 | return ( |
| 13 | <div> |
| 14 | {items.length && <ItemList items={items} />} |
| 15 | // ^^^ react/jsx-no-leaked-render (0 renders as "0") |
| 16 | |
| 17 | {items.map((item, index) => ( |
| 18 | <div key={index}>{item.name}</div> |
| 19 | // ^^^ react/no-array-index-key |
| 20 | ))} |
| 21 | |
| 22 | <button onClick={handleSubmit}>Save</button> |
| 23 | // ^^^ react/button-has-type (defaults to "submit" in form) |
| 24 | </div> |
| 25 | ); |
| 26 | } |
| 27 | |
| 28 | // After fixing |
| 29 | function UserProfile({ user }: { user: User }) { |
| 30 | const [items, setItems] = useState<Item[]>([]); |
| 31 | |
| 32 | useEffect(() => { |
| 33 | fetchItems().then(setItems); |
| 34 | }, []); |
| 35 | |
| 36 | return ( |
| 37 | <div> |
| 38 | {items.length > 0 && <ItemList items={items} />} |
| 39 | |
| 40 | {items.map((item) => ( |
| 41 | <div key={item.id}>{item.name}</div> |
| 42 | ))} |
| 43 | |
| 44 | <button type="button" onClick={handleSubmit}> |
| 45 | Save |
| 46 | </button> |
| 47 | </div> |
| 48 | ); |
| 49 | } |
best practice
Create custom ESLint rules to enforce project-specific conventions. Rules are functions that traverse the AST and report violations.
| 1 | // eslint-rules/no-barrel-imports.js |
| 2 | // Custom rule: forbid barrel imports (index.ts) in specific contexts |
| 3 | |
| 4 | export default { |
| 5 | meta: { |
| 6 | type: "suggestion", |
| 7 | docs: { |
| 8 | description: "Forbid barrel imports from feature directories", |
| 9 | recommended: false, |
| 10 | }, |
| 11 | schema: [ |
| 12 | { |
| 13 | type: "object", |
| 14 | properties: { |
| 15 | directories: { |
| 16 | type: "array", |
| 17 | items: { type: "string" }, |
| 18 | }, |
| 19 | }, |
| 20 | additionalProperties: false, |
| 21 | }, |
| 22 | ], |
| 23 | messages: { |
| 24 | noBarrel: |
| 25 | "Avoid barrel imports from '{{path}}'. Import directly from the module file.", |
| 26 | }, |
| 27 | }, |
| 28 | create(context) { |
| 29 | const directories = context.options[0]?.directories ?? []; |
| 30 | |
| 31 | return { |
| 32 | ImportDeclaration(node) { |
| 33 | const source = node.source.value; |
| 34 | |
| 35 | for (const dir of directories) { |
| 36 | if (source.startsWith(dir)) { |
| 37 | context.report({ |
| 38 | node, |
| 39 | messageId: "noBarrel", |
| 40 | data: { path: source }, |
| 41 | }); |
| 42 | return; |
| 43 | } |
| 44 | } |
| 45 | }, |
| 46 | }; |
| 47 | }, |
| 48 | }; |
| 49 | |
| 50 | // Usage in eslint.config.js: |
| 51 | // { |
| 52 | // plugins: { "custom": customPlugin }, |
| 53 | // rules: { |
| 54 | // "custom/no-barrel-imports": ["error", { |
| 55 | // directories: ["features/", "pages/"] |
| 56 | // }], |
| 57 | // }, |
| 58 | // } |
pro tip
Standardize linting and formatting commands in package.json for consistent execution across environments.
| 1 | // package.json scripts |
| 2 | { |
| 3 | "scripts": { |
| 4 | "lint": "eslint . --max-warnings=0", |
| 5 | "lint:fix": "eslint . --fix", |
| 6 | "format": "prettier --write .", |
| 7 | "format:check": "prettier --check .", |
| 8 | "typecheck": "tsc --noEmit", |
| 9 | "lint-staged": "lint-staged", |
| 10 | "prepare": "husky" |
| 11 | } |
| 12 | } |
| 13 | |
| 14 | // CI pipeline integration: |
| 15 | // 1. npm run format:check |
| 16 | // 2. npm run lint |
| 17 | // 3. npm run typecheck |
| 18 | // 4. npm run test |
| 19 | // 5. npm run build |
| 1 | # .github/workflows/lint.yml |
| 2 | name: Lint & Format Check |
| 3 | on: |
| 4 | push: |
| 5 | branches: [main] |
| 6 | pull_request: |
| 7 | |
| 8 | jobs: |
| 9 | quality: |
| 10 | runs-on: ubuntu-latest |
| 11 | steps: |
| 12 | - uses: actions/checkout@v4 |
| 13 | - uses: actions/setup-node@v4 |
| 14 | with: |
| 15 | node-version: 20 |
| 16 | cache: "npm" |
| 17 | - run: npm ci |
| 18 | - run: npm run format:check |
| 19 | - run: npm run lint |
| 20 | - run: npm run typecheck |
best practice