Every professional JavaScript project eventually faces the same challenge: keeping code consistent, readable, and free of bugs across a growing team. Two tools have become the undisputed standard for solving this problem — ESLint for catching errors and enforcing coding patterns, and Prettier for automatic code formatting. Together, they form a powerful quality gate that catches issues before they ever reach code review.
This guide covers everything you need to set up ESLint and Prettier in a modern JavaScript or TypeScript project. You will learn how to configure the new ESLint flat config system, integrate Prettier without rule conflicts, automate formatting with Git hooks, and wire it all into your CI/CD pipeline. Whether you are starting a new project or retrofitting an existing codebase, by the end of this article you will have a production-ready setup.
Why Code Quality Tools Matter
Manual code reviews catch design issues and logical errors, but they are poor at enforcing trivial consistency rules. Debating tabs versus spaces or trailing commas in a code review wastes everyone’s time. Automated tooling eliminates that entire category of discussion by enforcing rules the moment a file is saved.
ESLint and Prettier address two distinct but complementary concerns. ESLint is a static analysis tool — it parses your code into an abstract syntax tree (AST) and runs rules against it. Those rules can detect unused variables, unreachable code, accessibility violations in JSX, and hundreds of other potential problems. Prettier, on the other hand, is an opinionated code formatter. It does not care whether your code is correct; it only cares about how it looks. It takes your code, discards all original formatting, and reprints it in a consistent style.
The key insight is that these tools should never overlap. ESLint handles logic and correctness. Prettier handles visual formatting. When you try to use ESLint for formatting (which older configurations often did), you end up with slower linting, confusing rule conflicts, and a worse developer experience. The modern approach is clear separation of concerns.
Understanding ESLint’s Flat Config System
ESLint 9 introduced a new configuration format called flat config (using eslint.config.js instead of the legacy .eslintrc files). As of 2025, the old configuration system is deprecated and flat config is the only supported format going forward. If you are still using .eslintrc.json or .eslintrc.js, now is the time to migrate.
The flat config system has several advantages over the legacy format. Configuration files are plain JavaScript modules, which means you get full IDE support, type checking, and the ability to use variables and logic. The cascading configuration model (where nested .eslintrc files in subdirectories override parent settings) is gone — replaced by a single, explicit array of configuration objects. This makes it much easier to understand exactly which rules apply to which files.
Each object in the configuration array can specify a files pattern, a set of rules, a languageOptions block for parser settings, and a plugins map. Objects later in the array override earlier ones for the same files, giving you precise control over rule precedence.
Complete ESLint Flat Config with TypeScript and React
Below is a production-ready ESLint configuration that works with TypeScript, React, and modern JavaScript. This configuration uses the flat config format and includes sensible defaults that work well for most projects.
// eslint.config.js
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import reactPlugin from "eslint-plugin-react";
import reactHooksPlugin from "eslint-plugin-react-hooks";
import jsxA11yPlugin from "eslint-plugin-jsx-a11y";
import importPlugin from "eslint-plugin-import";
import prettierConfig from "eslint-config-prettier";
export default tseslint.config(
// Global ignores — replaces .eslintignore
{
ignores: [
"dist/**",
"build/**",
"coverage/**",
"node_modules/**",
"*.config.{js,ts}",
"public/**",
],
},
// Base JavaScript rules
js.configs.recommended,
// TypeScript rules (strict preset)
...tseslint.configs.strictTypeChecked,
...tseslint.configs.stylisticTypeChecked,
// TypeScript parser options (applied to all TS files)
{
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
// React configuration
{
files: ["**/*.{tsx,jsx}"],
plugins: {
react: reactPlugin,
"react-hooks": reactHooksPlugin,
"jsx-a11y": jsxA11yPlugin,
},
languageOptions: {
parserOptions: {
ecmaFeatures: { jsx: true },
},
},
settings: {
react: { version: "detect" },
},
rules: {
...reactPlugin.configs.recommended.rules,
...reactHooksPlugin.configs.recommended.rules,
...jsxA11yPlugin.configs.recommended.rules,
"react/react-in-jsx-scope": "off",
"react/prop-types": "off",
"react/jsx-no-target-blank": "error",
"react-hooks/exhaustive-deps": "warn",
},
},
// Import organization rules
{
plugins: { import: importPlugin },
rules: {
"import/order": [
"error",
{
groups: [
"builtin",
"external",
"internal",
"parent",
"sibling",
"index",
],
"newlines-between": "always",
alphabetize: { order: "asc", caseInsensitive: true },
},
],
"import/no-duplicates": "error",
"import/no-unresolved": "off",
},
},
// Custom rule overrides
{
rules: {
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
destructuredArrayIgnorePattern: "^_",
},
],
"@typescript-eslint/consistent-type-imports": [
"error",
{ prefer: "type-imports", fixStyle: "inline-type-imports" },
],
"@typescript-eslint/no-misused-promises": [
"error",
{ checksVoidReturn: { attributes: false } },
],
"no-console": ["warn", { allow: ["warn", "error"] }],
},
},
// Test file overrides — relaxed rules for test files
{
files: ["**/*.test.{ts,tsx}", "**/*.spec.{ts,tsx}"],
rules: {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-unsafe-assignment": "off",
},
},
// Disable all formatting rules (Prettier handles formatting)
prettierConfig
);
Let’s break down the key decisions in this configuration. The tseslint.configs.strictTypeChecked preset enables the most thorough TypeScript checks, including rules that require type information from the compiler. The projectService option is the modern way to connect ESLint to your TypeScript configuration — it replaces the older project option and is significantly faster in monorepo setups.
The eslint-config-prettier import at the end is critical. It disables every ESLint rule that would conflict with Prettier’s formatting. By placing it last in the configuration array, it overrides any formatting rules that earlier configurations might have enabled. This is the correct way to prevent ESLint and Prettier from fighting each other.
Notice the import ordering rules. Consistent import organization makes files easier to scan and reduces merge conflicts. The configuration groups imports by type (built-in Node.js modules first, then external packages, then internal modules) and alphabetizes within each group.
Setting Up Prettier
Prettier’s philosophy is minimal configuration. The fewer options you customize, the less there is to argue about. That said, most teams adjust a handful of settings to match their preferences. Create a .prettierrc file in your project root with your chosen options, and a .prettierignore file to exclude generated files.
Prettier works well with every major code editor. VS Code, WebStorm, and Neovim all have official plugins that format files on save. This immediate feedback loop is important — developers should see formatted code the moment they save, not when they run a separate command.
Complete Prettier and Pre-Commit Hook Setup
The second piece of the puzzle is ensuring that no unformatted or unlinted code ever makes it into the repository. The industry standard approach uses Husky for Git hooks and lint-staged for running tools only on changed files. Here is the complete setup, including the Prettier configuration and all the scripts you need.
// .prettierrc
{
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "all",
"printWidth": 80,
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf",
"jsxSingleQuote": false,
"bracketSameLine": false,
"plugins": ["prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.md",
"options": { "proseWrap": "always" }
},
{
"files": "*.json",
"options": { "tabWidth": 2, "trailingComma": "none" }
}
]
}
// .prettierignore
dist
build
coverage
node_modules
pnpm-lock.yaml
package-lock.json
*.min.js
*.min.css
// --- Package.json scripts and lint-staged config ---
// Add these to your package.json:
{
"scripts": {
"lint": "eslint . --max-warnings 0",
"lint:fix": "eslint . --fix --max-warnings 0",
"format": "prettier --write .",
"format:check": "prettier --check .",
"typecheck": "tsc --noEmit",
"quality": "npm run typecheck && npm run lint && npm run format:check",
"prepare": "husky"
},
"lint-staged": {
"*.{ts,tsx,js,jsx}": [
"eslint --fix --max-warnings 0",
"prettier --write"
],
"*.{json,md,yml,yaml,css,scss}": [
"prettier --write"
]
}
}
// --- Terminal commands to install everything ---
// Run these commands in your project root:
// 1. Install ESLint and TypeScript plugins
// npm install -D eslint @eslint/js typescript-eslint
// eslint-plugin-react eslint-plugin-react-hooks
// eslint-plugin-jsx-a11y eslint-plugin-import
// eslint-config-prettier
// 2. Install Prettier and optional plugins
// npm install -D prettier prettier-plugin-tailwindcss
// 3. Install Git hook tooling
// npm install -D husky lint-staged
// 4. Initialize Husky
// npx husky init
// 5. Create the pre-commit hook file
// echo "npx lint-staged" > .husky/pre-commit
// --- GitHub Actions CI integration ---
// .github/workflows/quality.yml
name: Code Quality
on:
pull_request:
branches: [main, develop]
push:
branches: [main]
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "npm"
- run: npm ci
- run: npm run typecheck
- run: npm run lint
- run: npm run format:check
This setup creates a multi-layered quality gate. During development, editor plugins provide instant feedback. When a developer commits code, the pre-commit hook runs ESLint and Prettier on staged files only (thanks to lint-staged), which keeps it fast even in large projects. Finally, the CI pipeline runs the full suite to catch anything that slipped through — for example, if someone committed with --no-verify.
The --max-warnings 0 flag is an intentional strictness choice. It means that any ESLint warning (not just errors) will fail the lint check. This prevents warnings from accumulating silently. If a rule is too noisy, the correct response is to either fix the violations or reconfigure the rule — not to ignore warnings indefinitely.
Resolving Conflicts Between ESLint and Prettier
The most common source of frustration when setting up these tools is rule conflicts. ESLint historically included many formatting rules (indentation, quotes, semicolons, and so on), and if those rules disagree with Prettier’s output, you get an endless cycle of fixes and errors.
The solution is straightforward: never use ESLint for formatting. The eslint-config-prettier package disables all ESLint rules that are purely about formatting. By including it as the last configuration in your flat config array, you guarantee that no formatting rule sneaks through from any plugin.
If you encounter a conflict, check which tool is producing the error. Formatting complaints (indentation, line breaks, spacing around operators) should come exclusively from Prettier. Logic complaints (unused variables, type mismatches, accessibility issues) should come exclusively from ESLint. If ESLint is producing a formatting-related error, either eslint-config-prettier is not properly included, or a plugin is introducing formatting rules that override it. Recheck your configuration order.
Editor Integration Best Practices
Proper editor configuration makes the entire system invisible to developers. The goal is that saving a file automatically formats it and shows lint errors inline. Here are the recommended VS Code settings for a project using ESLint and Prettier together.
Add a .vscode/settings.json file to your repository (and commit it — these are project settings, not personal preferences). Set Prettier as the default formatter for JavaScript and TypeScript files. Enable format-on-save. Enable ESLint’s auto-fix-on-save for the fixable subset of rules (like import ordering). Disable any built-in formatting that might interfere.
For teams using different editors, the .editorconfig file provides a universal baseline. While Prettier overrides EditorConfig for the files it processes, EditorConfig still governs files that Prettier does not handle (like Makefiles or shell scripts). Keep the two aligned to avoid confusion.
Handling Monorepos and Large Codebases
In a monorepo setup, you typically want a shared ESLint configuration that all packages inherit from, with per-package overrides for specific needs. The flat config system handles this naturally — you can import a shared configuration from a workspace package and spread it into each package’s local eslint.config.js.
Performance becomes a concern in large codebases. ESLint’s type-checked rules are the slowest because they invoke the TypeScript compiler. The projectService option improves this by sharing a single compiler instance, but you should still monitor lint times. Consider running type-checked rules only in CI and using a faster, non-type-checked configuration for editor integration and pre-commit hooks.
Prettier is generally fast, but can slow down on very large files. The .prettierignore file should exclude generated code, vendor bundles, and any file that does not benefit from formatting. Lock files (pnpm-lock.yaml, package-lock.json) should always be ignored.
Working with Frameworks
Different JavaScript frameworks have specific ESLint plugins that add framework-aware rules. React projects benefit from eslint-plugin-react-hooks (which catches violations of the Rules of Hooks) and eslint-plugin-jsx-a11y (which flags accessibility issues in JSX). Vue projects should use eslint-plugin-vue, which understands single-file component syntax. Svelte has eslint-plugin-svelte with similar capabilities.
When adding framework plugins, always check whether they introduce formatting rules and disable those with eslint-config-prettier. The Vue plugin in particular includes several formatting rules by default that conflict with Prettier’s handling of template syntax.
Integrating with AI Code Assistants
Modern AI code assistants generate code that may not match your project’s style conventions. ESLint and Prettier act as a safety net here — any AI-generated code that does not conform to your rules will be automatically flagged or reformatted. This is another reason to use format-on-save: the moment you accept an AI suggestion, it is immediately reformatted to match your project style.
Some teams configure their AI assistants to read the ESLint and Prettier configuration files, providing context about the project’s coding standards. This reduces the amount of post-generation formatting needed, though it does not eliminate the need for automated tooling.
Version Control Considerations
When introducing ESLint and Prettier to an existing codebase, the initial formatting commit will touch many files. This can make Git history harder to navigate with git blame. The standard mitigation is to create a .git-blame-ignore-revs file containing the SHA of the formatting commit. GitHub and modern Git clients respect this file and skip those commits when displaying blame information.
Run the initial formatting as a single, isolated commit with a clear message like “chore: apply Prettier formatting to entire codebase.” Do not mix formatting changes with logical changes. This keeps the history clean and makes the ignore-revs approach effective.
Advanced ESLint Techniques
Beyond the standard rule sets, ESLint supports custom rules and plugins that address project-specific concerns. You can write rules that enforce architectural boundaries (for example, preventing imports from a domain layer into a UI layer), ban specific API usage patterns, or enforce naming conventions for files and exports.
The no-restricted-imports rule is particularly useful. You can use it to ban direct imports from large libraries (forcing tree-shakeable imports), redirect deprecated module paths, or enforce that certain utilities are always imported from a centralized location.
For teams working on projects that require careful code review processes, ESLint can also enforce documentation standards. Rules can require JSDoc comments on exported functions, validate parameter documentation, and ensure that public APIs are properly documented.
Measuring Code Quality Over Time
Setting up linting and formatting is only the beginning. To ensure sustained quality, track metrics over time. The number of ESLint warnings in the codebase, the percentage of files passing strict type checks, and the frequency of lint-staged failures are all useful indicators.
For agencies and development teams managing multiple client projects, maintaining consistent code quality standards across repositories is essential. Tools like Toimi help development teams streamline their project workflows and maintain quality benchmarks across every engagement. Pairing strong project management with automated code quality tooling ensures that standards are not just documented but actively enforced.
Similarly, teams that track tasks granularly with tools like Taskee can create specific quality-related tasks — such as fixing ESLint warnings, updating deprecated rules, or migrating to new configuration formats — ensuring these maintenance items do not fall through the cracks.
Migration Checklist
If you are migrating from an older ESLint setup (using .eslintrc and formatting rules), follow this sequence to avoid disruptions:
- Audit current rules. Export your resolved configuration with
eslint --print-config file.jsand identify which rules are formatting-related. - Install Prettier. Add Prettier with your chosen configuration and run it on the entire codebase in a single commit.
- Add eslint-config-prettier. This disables all conflicting ESLint rules. Verify with
npx eslint-config-prettier path/to/file.jsthat no conflicts remain. - Migrate to flat config. Create
eslint.config.js, translate your rules, and delete all.eslintrcfiles. ESLint provides a migration tool:npx @eslint/migrate-config .eslintrc.json. - Set up Git hooks. Install Husky and lint-staged to enforce the new setup on every commit.
- Update CI. Ensure your pipeline runs both
eslintandprettier --checkseparately. - Communicate with the team. Share editor configuration files and document the new workflow.
Common Pitfalls and How to Avoid Them
Several patterns consistently cause problems in ESLint and Prettier setups. Here are the most frequent mistakes and their solutions:
Running Prettier through ESLint. The eslint-plugin-prettier package runs Prettier as an ESLint rule and reports formatting differences as ESLint errors. While this technically works, it is slower than running Prettier directly, produces harder-to-read error messages, and merges two distinct concerns. The recommended approach is to run them as separate tools.
Inconsistent configurations across packages. In a monorepo, each package having its own slightly different ESLint configuration leads to drift and confusion. Extract shared rules into a dedicated configuration package and extend it everywhere.
Ignoring TypeScript strict mode. The strictTypeChecked preset is significantly more thorough than the base recommended preset. It catches real bugs — like accidentally awaiting a non-Promise value or using a potentially undefined value without checking. The strictness is worth the initial effort of fixing violations.
Not using the --max-warnings 0 flag. Without it, warnings accumulate until they are meaningless noise. Either fix warnings or reconfigure rules to errors or off — do not let them linger.
Frequently Asked Questions
What is the difference between ESLint and Prettier?
ESLint is a static analysis tool that finds and fixes problems in your JavaScript code — things like unused variables, type errors, accessibility violations, and incorrect API usage. Prettier is a code formatter that enforces consistent visual style — indentation, line breaks, quote styles, and bracket placement. ESLint focuses on code correctness and best practices, while Prettier focuses exclusively on how code looks. In a modern setup, they run as separate tools with no overlapping responsibilities.
Should I use eslint-plugin-prettier or eslint-config-prettier?
Use eslint-config-prettier, not eslint-plugin-prettier. The config package simply disables ESLint rules that would conflict with Prettier, allowing both tools to run independently without interference. The plugin package, on the other hand, runs Prettier inside ESLint and reports formatting differences as lint errors. While the plugin approach works, it is slower, produces confusing error output, and unnecessarily couples the two tools. The official Prettier documentation recommends running the tools separately.
How do I migrate from .eslintrc to the new flat config format?
ESLint provides an official migration tool. Run npx @eslint/migrate-config .eslintrc.json to generate an initial eslint.config.js file based on your existing configuration. Review the output, adjust plugin imports to use the new flat config API, and test thoroughly. Key differences include: plugins are now imported as JavaScript modules rather than referenced by string name, the env property is replaced by languageOptions.globals, and the extends keyword is replaced by spreading shared configurations into the config array. The overrides property is replaced by multiple config objects with files patterns.
Does using ESLint with TypeScript slow down my development workflow?
Type-checked ESLint rules do add overhead because they invoke the TypeScript compiler. However, the new projectService option in typescript-eslint significantly improves performance by sharing a single compiler instance across all files. For editor integration, consider using a non-type-checked configuration (the recommended preset instead of strictTypeChecked) to keep feedback instant, and reserve type-checked rules for pre-commit hooks and CI. In most projects under 1,000 files, even type-checked rules complete in a few seconds.
Can ESLint and Prettier work with monorepos managed by Turborepo or Nx?
Yes, and monorepos benefit greatly from centralized quality tooling. Create a shared ESLint configuration package in your workspace (for example, @myorg/eslint-config) that exports a base flat config array. Each package imports and extends this shared config, adding only package-specific overrides. Prettier configuration can be a single .prettierrc at the workspace root — Prettier automatically applies the closest configuration file. Use Turborepo or Nx to cache lint results, so unchanged packages are not re-linted on every run. Lint-staged at the root level handles pre-commit hooks for the entire workspace.
Conclusion
ESLint and Prettier are foundational tools for any JavaScript or TypeScript project. ESLint catches bugs and enforces best practices. Prettier eliminates formatting debates by applying a consistent style automatically. Together, with Git hooks and CI integration, they create a quality gate that requires zero developer discipline to maintain — the tools enforce the rules so the team can focus on building features.
The flat config system in ESLint 9 makes configuration clearer and more powerful than ever. Combined with Prettier, lint-staged, and Husky, you get a complete workflow that catches issues at every stage: in the editor, at commit time, and in the CI pipeline. Invest the time to set it up once, and your team will benefit on every commit thereafter.