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.
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'smax-len,printWidthis a target, not a ceiling.tabWidth(default: 2): Number of spaces per indentation level. Respects.editorconfigsettings 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.
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 ofrecommendedthat 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-tailwindcssFor a simpler TypeScript setup using the typescript-eslint unified package:
npm install -D eslint @eslint/js typescript typescript-eslintThe 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 initConfigure 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-stagedThe 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:checkSeparate 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.
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:
- Start with
warnfor all rules that produce existing violations - Use
.eslintignoreto exclude files that have not been touched - Promote rules to
erroras files are refactored - 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
-
Use
eslint-config-prettieras the last config: Always place it last so it overrides any formatting rules from other configs. -
Use lint-staged instead of linting the entire project: Lint-staged only checks staged files, making pre-commit hooks fast even in large codebases.
-
Start with recommended configs: Extend from
eslint:recommendedand@typescript-eslint/recommendedbefore adding custom rules. -
Use
--fixin development, not in CI: In development, auto-fix saves time. In CI, report errors without fixing them so developers see what needs to change. -
Configure IDE integration: Ensure VS Code runs ESLint and Prettier on save. This provides instant feedback and prevents violations from accumulating.
-
Use
.prettierignoreaggressively: Ignore generated files, lock files, and build outputs. -
Document custom rules: If you add non-obvious rules, add comments explaining why.
-
Version your configuration: Treat ESLint and Prettier configs as code. Changes should go through code review.
-
Use
overridesfor file-specific settings: Prettier'soverridesoption lets you configure different formatting rules for different file types, like markdown prose wrapping or CSS quote styles. -
Set
endOfLineto"lf": This prevents cross-platform line-ending issues in git repositories. Windows users withautocrlfenabled will still see CRLF in their local files, but the repository will consistently use LF.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
| ESLint and Prettier conflicting | Infinite fix loops | Use eslint-config-prettier to disable ESLint formatting rules |
Missing eslint-config-prettier | Both tools format the same code | Always install and configure it |
| Linting entire project in pre-commit | Slow commits (30+ seconds) | Use lint-staged to lint only changed files |
| Not ignoring generated files | Linting errors in auto-generated code | Add to .eslintignore and .prettierignore |
Using deprecated .eslintrc format | Config errors in ESLint v9+ | Migrate to flat config (eslint.config.js) |
Type-aware rules without project | Rules silently not working | Set parserOptions.project |
| Conflicting editor settings | Format on save differs from Prettier | Set editor to use Prettier as default formatter |
Forgetting --fix in development | Manual fix for auto-fixable issues | Configure eslint --fix in npm scripts |
Not using defineConfig() helper | Config type checking errors | Use 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
| Feature | ESLint + Prettier | Biome | Deno Lint |
|---|---|---|---|
| Linting | Excellent | Good | Good |
| Formatting | Prettier (excellent) | Built-in | No |
| Language Support | JS, TS, JSX, TSX, Vue, Svelte | JS, TS, JSX, TSX | JS, TS |
| Plugin Ecosystem | Largest | Growing | Minimal |
| Configuration | Extensive | Minimal | Minimal |
| Speed | Fast | Fastest | Fast |
| IDE Support | Excellent | Good | Limited |
| Community | Largest | Growing | Small |
| Migration Path | N/A | ESLint + Prettier preset | N/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:
- Developers add
/** @format */to files they have formatted - CI uses
--require-pragmato only check files with the pragma - As files are touched, they get the pragma and join the formatted set
- Once all files are migrated, remove
--require-pragmato 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:
-
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.
-
Prettier eliminates formatting debates: Consistent formatting across the entire codebase means no more code review comments about indentation, semicolons, or line length.
-
Pre-commit hooks enforce standards automatically: With Husky and lint-staged, every commit is checked before it enters the repository.
-
The flat config system simplifies configuration: ESLint v9's flat config is explicit, composable, and easier to understand than the older cascading configuration model.
-
Type-aware linting catches deep bugs: Rules like
no-floating-promisesandawait-thenableuse the TypeScript compiler to catch issues that basic AST analysis cannot detect. -
CI integration catches issues early: Running linting and formatting checks in CI ensures that all code meets quality standards before it is merged.
-
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.