MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

ESLint and Prettier: Code Quality Automation

Set up ESLint and Prettier: configuration, integration, and common rule patterns.

ESLintPrettierCode QualityJavaScript

By MinhVo

Introduction

Code quality is not a luxury—it is a necessity for any team that ships software regularly. Inconsistent formatting, forgotten semicolons, unused variables, and subtle logic errors creep into codebases when there are no automated guardrails. ESLint and Prettier are the two most widely adopted tools for enforcing code quality in JavaScript and TypeScript projects. ESLint identifies problematic patterns and enforces coding conventions through static analysis. Prettier enforces consistent formatting by parsing and reprinting code with its own rules. Together, they eliminate an entire class of code review comments about style and focus reviews on logic and architecture.

The challenge for most developers is not understanding what ESLint and Prettier do individually, but configuring them to work together without conflicts. ESLint has formatting rules that overlap with Prettier's formatting. If both tools try to format the same code, they produce conflicting results. The solution is to disable ESLint's formatting rules and let Prettier handle formatting while ESLint handles logic and patterns. This separation of concerns is simple in principle but requires careful configuration in practice.

This guide covers everything from initial setup to advanced configuration patterns. We will explore the flat config system introduced in ESLint v9, configure Prettier for different project types, set up pre-commit hooks to enforce standards automatically, integrate with TypeScript for type-aware linting, and discuss the patterns that make these tools effective in production codebases.

Code quality tools

Understanding ESLint and Prettier: Core Concepts

ESLint Architecture

ESLint works by parsing your code into an Abstract Syntax Tree (AST) and running rules against the tree. Each rule inspects specific node types and reports violations. The flat config system (eslint.config.js) replaces the older .eslintrc format and provides a more explicit, composable configuration model.

The flat config system was introduced to solve several problems with the legacy configuration approach. The old cascading config model (.eslintrc files at different directory levels that merged together) was confusing and hard to debug. Developers would inherit rules from parent directories without realizing it. The flat config makes every rule's origin explicit—no hidden merging, no surprises.

// eslint.config.js
import js from '@eslint/js';
import tsPlugin from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
import prettierConfig from 'eslint-config-prettier';
 
export default [
  js.configs.recommended,
  {
    files: ['**/*.{ts,tsx}'],
    languageOptions: {
      parser: tsParser,
      parserOptions: {
        ecmaVersion: 'latest',
        sourceType: 'module',
        ecmaFeatures: { jsx: true },
      },
    },
    plugins: {
      '@typescript-eslint': tsPlugin,
    },
    rules: {
      '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
      '@typescript-eslint/explicit-function-return-type': 'off',
      '@typescript-eslint/no-explicit-any': 'warn',
      '@typescript-eslint/consistent-type-imports': 'error',
      'no-console': ['warn', { allow: ['warn', 'error'] }],
      'no-debugger': 'error',
      'prefer-const': 'error',
      'no-var': 'error',
      'eqeqeq': ['error', 'always'],
    },
  },
  prettierConfig, // Disable ESLint formatting rules that conflict with Prettier
];

Prettier Configuration

Prettier has very few options by design. The philosophy is that consistent formatting matters more than personal preferences. The tool parses your code into an AST and reprints it from scratch, ignoring the original formatting entirely. This is fundamentally different from other formatters that try to modify only the parts that violate rules.

The key Prettier options and their impact on code:

  • printWidth (default: 80): Not a hard limit—Prettier will produce lines both shorter and longer, but generally targets this width. Unlike ESLint's max-len, printWidth is a target, not a ceiling.
  • tabWidth (default: 2): Number of spaces per indentation level. Respects .editorconfig settings automatically.
  • semi (default: true): Whether to add semicolons at the end of statements.
  • singleQuote (default: false): Whether to use single quotes instead of double quotes.
  • trailingComma (default: "all"): Controls trailing commas in multi-line constructs. Changed from "es5" to "all" in Prettier v3.
  • arrowParens (default: "always"): Whether to include parentheses around single arrow function parameters. Changed from "avoid" to "always" in Prettier v2 for better editability.
// .prettierrc
{
  "semi": true,
  "singleQuote": true,
  "tabWidth": 2,
  "trailingComma": "all",
  "printWidth": 100,
  "bracketSpacing": true,
  "arrowParens": "always",
  "endOfLine": "lf",
  "plugins": ["prettier-plugin-tailwindcss"]
}

How They Work Together

The key insight is that ESLint handles code quality (logic, patterns, best practices) while Prettier handles formatting (indentation, line length, semicolons). The eslint-config-prettier package disables all ESLint rules that would conflict with Prettier.

Without eslint-config-prettier, ESLint and Prettier fight each other—ESLint says "add a semicolon" and Prettier says "remove it," creating an infinite loop of fixes. The eslint-config-prettier package exports a configuration that turns off every ESLint rule that is unnecessary or might conflict with Prettier. It does not enable any rules itself—it only disables formatting rules.

The order matters: eslint-config-prettier must be the last configuration in your flat config array so it can override any formatting rules enabled by earlier configurations.

ESLint and Prettier workflow

Architecture and Design Patterns

TypeScript Integration with Type-Aware Linting

For TypeScript projects, you can go beyond basic syntax linting and enable type-aware rules that use the TypeScript compiler's type information. This catches errors that simple AST analysis cannot detect—like floating promises, misused promises, and incorrect await usage.

The typescript-eslint package provides three configuration presets:

  • recommended: Basic rules that catch common mistakes without requiring type information. Fast and works out of the box.
  • strict: A superset of recommended that includes more opinionated rules which may also catch additional bugs.
  • stylistic: Additional rules that enforce consistent styling without significantly catching bugs or changing logic.

For type-aware linting, you must specify parserOptions.project pointing to your tsconfig.json. This enables rules that require type information:

// eslint.config.mjs (TypeScript with type-aware linting)
import js from '@eslint/js';
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';
 
export default defineConfig({
  files: ['**/*.{ts,tsx}'],
  extends: [
    js.configs.recommended,
    tseslint.configs.recommended,
    tseslint.configs.strict,
    tseslint.configs.stylistic,
  ],
  languageOptions: {
    parserOptions: {
      project: './tsconfig.json',
    },
  },
  rules: {
    // Type-aware rules (require parserOptions.project)
    '@typescript-eslint/no-floating-promises': 'error',
    '@typescript-eslint/no-misused-promises': 'error',
    '@typescript-eslint/await-thenable': 'error',
    '@typescript-eslint/require-await': 'error',
    '@typescript-eslint/no-unnecessary-type-assertion': 'error',
    '@typescript-eslint/prefer-nullish-coalescing': 'warn',
    '@typescript-eslint/prefer-optional-chain': 'warn',
  },
});

Type-aware rules catch bugs that basic linting cannot. For example, no-floating-promises flags cases where a Promise is created but never awaited or has its rejection handled:

// ❌ Caught by @typescript-eslint/no-floating-promises
async function processOrder(orderId: string) {
  sendConfirmationEmail(orderId); // Error: Promise not handled
  await sendConfirmationEmail(orderId); // âś… Correct
  sendConfirmationEmail(orderId).catch(console.error); // âś… Correct
}

React Integration

React projects benefit from ESLint plugins that enforce hook rules and JSX best practices:

import reactPlugin from 'eslint-plugin-react';
import reactHooksPlugin from 'eslint-plugin-react-hooks';
import jsxA11yPlugin from 'eslint-plugin-jsx-a11y';
 
export default [
  {
    files: ['**/*.{tsx,jsx}'],
    plugins: {
      'react': reactPlugin,
      'react-hooks': reactHooksPlugin,
      'jsx-a11y': jsxA11yPlugin,
    },
    settings: {
      react: { version: 'detect' },
    },
    rules: {
      'react/react-in-jsx-scope': 'off',
      'react/prop-types': 'off',
      'react/jsx-key': 'error',
      'react/jsx-no-duplicate-props': 'error',
      'react/jsx-no-useless-fragment': 'error',
      'react/self-closing-comp': 'error',
      'react/jsx-boolean-value': ['error', 'never'],
      'react-hooks/rules-of-hooks': 'error',
      'react-hooks/exhaustive-deps': 'warn',
      'jsx-a11y/alt-text': 'error',
      'jsx-a11y/anchor-is-valid': 'error',
      'jsx-a11y/click-events-have-key-events': 'warn',
    },
  },
];

The react-hooks/rules-of-hooks rule is particularly valuable—it catches violations of the Rules of Hooks at lint time rather than at runtime. The exhaustive-deps rule warns when dependency arrays in useEffect, useMemo, and useCallback are missing values, which is one of the most common sources of stale closure bugs in React applications.

Step-by-Step Implementation

Setting Up from Scratch

Install dependencies:

# Core packages
npm install -D eslint prettier eslint-config-prettier
 
# TypeScript support
npm install -D @typescript-eslint/eslint-plugin @typescript-eslint/parser
 
# React support
npm install -D eslint-plugin-react eslint-plugin-react-hooks eslint-plugin-jsx-a11y
 
# Prettier Tailwind plugin (if using Tailwind)
npm install -D prettier-plugin-tailwindcss

For a simpler TypeScript setup using the typescript-eslint unified package:

npm install -D eslint @eslint/js typescript typescript-eslint

The typescript-eslint package bundles the parser, plugin, and configuration utilities into a single dependency, simplifying version management.

Setting Up Pre-Commit Hooks

Without pre-commit hooks, linting and formatting are optional—developers must remember to run them. With hooks, every commit is automatically checked. Install Husky and lint-staged:

npm install -D husky lint-staged
npx husky init

Configure lint-staged in package.json:

{
  "scripts": {
    "lint": "eslint . --fix",
    "format": "prettier --write .",
    "format:check": "prettier --check .",
    "check": "npm run lint && npm run format:check"
  },
  "lint-staged": {
    "*.{ts,tsx,js,jsx}": [
      "eslint --fix",
      "prettier --write"
    ],
    "*.{json,md,css,scss}": [
      "prettier --write"
    ]
  }
}

Configure the pre-commit hook:

# .husky/pre-commit
npx lint-staged

The key performance advantage of lint-staged is that it only processes staged files, not the entire codebase. In a project with thousands of files, this reduces pre-commit hook execution from minutes to seconds.

CI Integration

Add linting and formatting checks to your CI pipeline. In CI, run checks without --fix so violations are reported as failures rather than silently corrected:

# .github/workflows/lint.yml
name: Lint
on: [push, pull_request]
 
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      - run: npm run lint
      - run: npm run format:check

Separate the lint and format check steps so that developers can see which tool reported the issue. If you combine them into a single step, a failure could come from either ESLint or Prettier, making it harder to diagnose.

IDE Integration

For the best developer experience, configure your editor to run ESLint and Prettier on save. In VS Code, add these settings to .vscode/settings.json:

{
  "editor.formatOnSave": true,
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": "explicit"
  },
  "eslint.validate": [
    "javascript",
    "typescript",
    "typescriptreact"
  ]
}

This provides instant feedback as developers type. Formatting happens automatically on save, and ESLint violations are highlighted inline with quick-fix suggestions. This eliminates the cycle of writing code, running the linter, fixing issues, and running the linter again.

Code quality pipeline

Real-World Use Cases and Case Studies

Use Case 1: Monorepo Configuration

Monorepos with multiple packages need shared ESLint configurations. Create a shared config package that each workspace imports:

// packages/eslint-config/index.js
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import prettierConfig from 'eslint-config-prettier';
 
export const baseConfig = [
  js.configs.recommended,
  tseslint.configs.recommended,
  prettierConfig,
];
 
// apps/web/eslint.config.js
import { baseConfig } from '@myorg/eslint-config';
import reactPlugin from 'eslint-plugin-react';
 
export default [
  ...baseConfig,
  {
    files: ['**/*.{tsx,jsx}'],
    plugins: { 'react': reactPlugin },
    rules: {
      'react/jsx-key': 'error',
      'react/jsx-no-useless-fragment': 'error',
    },
  },
];

Use Case 2: Legacy Code Migration

When introducing ESLint to a legacy codebase, a big-bang approach produces thousands of errors and blocks all other work. Instead, adopt incrementally:

  1. Start with warn for all rules that produce existing violations
  2. Use .eslintignore to exclude files that have not been touched
  3. Promote rules to error as files are refactored
  4. Remove ignores as files are cleaned up
// Phase 1: Warnings only
rules: {
  '@typescript-eslint/no-explicit-any': 'warn',
  'no-console': 'off',
  '@typescript-eslint/no-unused-vars': 'warn',
}
 
// Phase 2: After cleanup, promote to errors
rules: {
  '@typescript-eslint/no-explicit-any': 'error',
  'no-console': ['warn', { allow: ['warn', 'error'] }],
  '@typescript-eslint/no-unused-vars': 'error',
}

The --report-unused-disable-directives option helps clean up legacy ESLint inline disable comments that no longer apply.

Use Case 3: Framework-Specific Rules

Different frameworks need different ESLint rules. Each major framework has a dedicated ESLint plugin:

// Next.js
import nextPlugin from '@next/eslint-plugin-next';
 
export default [
  {
    plugins: { '@next/next': nextPlugin },
    rules: {
      '@next/next/no-html-link-for-pages': 'error',
      '@next/next/no-img-element': 'error',
      '@next/next/no-sync-scripts': 'error',
      '@next/next/google-font-display': 'warn',
    },
  },
];
// Vue
import vuePlugin from 'eslint-plugin-vue';
 
export default [
  ...vuePlugin.configs['flat/recommended'],
  {
    rules: {
      'vue/multi-word-component-names': 'off',
      'vue/component-definition-name-casing': ['error', 'PascalCase'],
    },
  },
];

Best Practices for Production

  1. Use eslint-config-prettier as the last config: Always place it last so it overrides any formatting rules from other configs.

  2. Use lint-staged instead of linting the entire project: Lint-staged only checks staged files, making pre-commit hooks fast even in large codebases.

  3. Start with recommended configs: Extend from eslint:recommended and @typescript-eslint/recommended before adding custom rules.

  4. Use --fix in development, not in CI: In development, auto-fix saves time. In CI, report errors without fixing them so developers see what needs to change.

  5. Configure IDE integration: Ensure VS Code runs ESLint and Prettier on save. This provides instant feedback and prevents violations from accumulating.

  6. Use .prettierignore aggressively: Ignore generated files, lock files, and build outputs.

  7. Document custom rules: If you add non-obvious rules, add comments explaining why.

  8. Version your configuration: Treat ESLint and Prettier configs as code. Changes should go through code review.

  9. Use overrides for file-specific settings: Prettier's overrides option lets you configure different formatting rules for different file types, like markdown prose wrapping or CSS quote styles.

  10. Set endOfLine to "lf": This prevents cross-platform line-ending issues in git repositories. Windows users with autocrlf enabled will still see CRLF in their local files, but the repository will consistently use LF.

Common Pitfalls and Solutions

PitfallImpactSolution
ESLint and Prettier conflictingInfinite fix loopsUse eslint-config-prettier to disable ESLint formatting rules
Missing eslint-config-prettierBoth tools format the same codeAlways install and configure it
Linting entire project in pre-commitSlow commits (30+ seconds)Use lint-staged to lint only changed files
Not ignoring generated filesLinting errors in auto-generated codeAdd to .eslintignore and .prettierignore
Using deprecated .eslintrc formatConfig errors in ESLint v9+Migrate to flat config (eslint.config.js)
Type-aware rules without projectRules silently not workingSet parserOptions.project
Conflicting editor settingsFormat on save differs from PrettierSet editor to use Prettier as default formatter
Forgetting --fix in developmentManual fix for auto-fixable issuesConfigure eslint --fix in npm scripts
Not using defineConfig() helperConfig type checking errorsUse defineConfig() for better TypeScript support in flat config

Performance Optimization

Large codebases need careful ESLint configuration to maintain fast lint times. Type-aware rules are the most expensive because they require the TypeScript compiler to build the full type information for each file. Restrict these rules to source files and use lighter rules for tests and configuration files:

// eslint.config.js - Optimize for large codebases
export default [
  {
    ignores: [
      'node_modules/**', 'dist/**', 'build/**',
      'coverage/**', '**/*.min.js', '**/*.d.ts',
      '*.generated.*', 'prisma/generated/**',
    ],
  },
  // Base rules for all files
  js.configs.recommended,
  // Type-aware rules only for source files
  {
    files: ['src/**/*.{ts,tsx}'],
    languageOptions: {
      parserOptions: { project: './tsconfig.json' },
    },
    rules: {
      '@typescript-eslint/no-floating-promises': 'error',
      '@typescript-eslint/no-misused-promises': 'error',
    },
  },
  // Lighter rules for test files
  {
    files: ['**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}'],
    rules: {
      '@typescript-eslint/no-explicit-any': 'off',
      'no-console': 'off',
    },
  },
];

For projects where linting speed is critical, consider using ESLint's --cache flag to only re-lint files that have changed since the last run. Add .eslintcache to your .gitignore:

npx eslint --cache .

Comparison with Alternatives

FeatureESLint + PrettierBiomeDeno Lint
LintingExcellentGoodGood
FormattingPrettier (excellent)Built-inNo
Language SupportJS, TS, JSX, TSX, Vue, SvelteJS, TS, JSX, TSXJS, TS
Plugin EcosystemLargestGrowingMinimal
ConfigurationExtensiveMinimalMinimal
SpeedFastFastestFast
IDE SupportExcellentGoodLimited
CommunityLargestGrowingSmall
Migration PathN/AESLint + Prettier presetN/A

Biome is a newer alternative that combines linting and formatting in a single tool written in Rust. It is significantly faster than ESLint + Prettier but has a smaller plugin ecosystem. For new projects, Biome is worth evaluating. For existing projects with custom ESLint rules and Prettier plugins, the migration cost may not be justified.

Advanced Patterns and Techniques

Custom ESLint Rules

Create project-specific rules for patterns unique to your codebase:

// eslint-rules/no-direct-api-call.js
export default {
  meta: {
    type: 'problem',
    docs: {
      description: 'Disallow direct API calls; use the API client instead',
    },
    fixable: 'code',
    schema: [],
  },
  create(context) {
    return {
      CallExpression(node) {
        if (
          node.callee.type === 'MemberExpression' &&
          node.callee.object.name === 'fetch'
        ) {
          context.report({
            node,
            message: 'Use the API client instead of direct fetch calls.',
          });
        }
      },
    };
  },
};

Test custom rules with ESLint's RuleTester:

import { RuleTester } from 'eslint';
import noDirectApiCall from '../eslint-rules/no-direct-api-call.js';
 
const ruleTester = new RuleTester({
  languageOptions: { ecmaVersion: 2022 },
});
 
ruleTester.run('no-direct-api-call', noDirectApiCall, {
  valid: ['api.get("/users")', 'client.fetch("/users")'],
  invalid: [
    {
      code: 'fetch("/users")',
      errors: [{ message: 'Use the API client instead of direct fetch calls.' }],
    },
    {
      code: 'window.fetch("/users")',
      errors: [{ message: 'Use the API client instead of direct fetch calls.' }],
    },
  ],
});

Prettier Overrides for Different File Types

Configure Prettier differently for different file types using the overrides option:

{
  "semi": true,
  "singleQuote": true,
  "overrides": [
    {
      "files": "*.md",
      "options": {
        "proseWrap": "always",
        "printWidth": 80
      }
    },
    {
      "files": "*.css",
      "options": {
        "singleQuote": false
      }
    },
    {
      "files": ["*.yml", "*.yaml"],
      "options": {
        "tabWidth": 2,
        "singleQuote": false
      }
    }
  ]
}

Gradual Adoption with Pragma

When migrating a large codebase to Prettier, use the --require-pragma and --insert-pragma options for gradual adoption:

  1. Developers add /** @format */ to files they have formatted
  2. CI uses --require-pragma to only check files with the pragma
  3. As files are touched, they get the pragma and join the formatted set
  4. Once all files are migrated, remove --require-pragma to enforce formatting everywhere

This approach was pioneered by Facebook during their adoption of Prettier across their codebase.

Future Outlook

ESLint continues to evolve with the flat config system becoming the default in v9 and beyond. The typescript-eslint team maintains tight integration with TypeScript releases, ensuring type-aware linting stays current with new language features. Prettier is expanding its language support and improving its formatting algorithms. The integration between ESLint and Prettier remains the standard for JavaScript code quality.

The broader trend toward automated code quality in CI/CD pipelines means these tools will continue to be essential. As AI-assisted coding becomes more common, automated linting and formatting become even more important for maintaining consistency—AI-generated code may follow different conventions than human-written code, and linting ensures uniformity across all contributions regardless of author.

The emergence of Biome and other Rust-based tools signals a shift toward faster, more integrated development tooling. However, ESLint's plugin ecosystem and Prettier's language support give them a significant advantage in the near term. Teams should evaluate their specific needs—custom rules, framework support, and language coverage—when choosing between established and emerging tools.

Conclusion

ESLint and Prettier together form the foundation of JavaScript code quality:

  1. ESLint catches logic errors and enforces patterns: Unused variables, incorrect equality checks, missing dependencies in hooks, and framework-specific anti-patterns are caught before they reach production.

  2. Prettier eliminates formatting debates: Consistent formatting across the entire codebase means no more code review comments about indentation, semicolons, or line length.

  3. Pre-commit hooks enforce standards automatically: With Husky and lint-staged, every commit is checked before it enters the repository.

  4. The flat config system simplifies configuration: ESLint v9's flat config is explicit, composable, and easier to understand than the older cascading configuration model.

  5. Type-aware linting catches deep bugs: Rules like no-floating-promises and await-thenable use the TypeScript compiler to catch issues that basic AST analysis cannot detect.

  6. CI integration catches issues early: Running linting and formatting checks in CI ensures that all code meets quality standards before it is merged.

  7. The ecosystem is mature and extensive: Plugins exist for every major framework and for specialized use cases like accessibility, import sorting, and barrel files.

Setting up ESLint and Prettier is one of the first things you should do on any JavaScript project. The investment in configuration pays dividends in code quality, developer experience, and reduced code review friction.