Prettier in a Real Project: Config, ESLint Conflicts, .prettierignore, and CI Checks

Key takeaways

Prettier reprints your code from its syntax tree, so formatting stops being a review topic. The hard part is not installing it but making the editor, the pre-commit hook and CI all run the same version with the same config, and keeping ESLint from fighting it.

Why a formatter instead of a style guide

Style guides written in prose get enforced by code review, which means reviewers spend attention on spacing instead of logic, and every contributor’s editor settings leak into the diff. Prettier removes the question entirely. It parses your file into a syntax tree, throws away the original whitespace and reprints the tree with its own rules, wrapping lines to fit printWidth:

// Input from three different people
function   add(a,b){return a+b}
function add( a , b ) {
  return a + b
}

// What Prettier prints for all of them
function add(a, b) {
  return a + b;
}

Because the output depends on the tree rather than the input’s layout, two people who write the same code get byte-identical files. That is the entire value proposition, and it is also why Prettier has few options: every option is another way for two setups to disagree.

What Prettier does not do: it does not rename things, reorder imports (unless you add a plugin), remove unused variables, or change semantics. It also does not touch things like disabled={true} in JSX; that is a lint concern, not a formatting one.


Installation and the two commands you need

npm install --save-dev --save-exact prettier

--save-exact matters more than it looks. Prettier’s output can change between minor versions (a new release may format some edge case differently), and with a ^3.x range one machine can have a slightly newer version than another. The result is --check failing for files nobody touched. Pin it and upgrade deliberately, in a commit that reformats the codebase.

npx prettier . --write   # rewrite files in place
npx prettier . --check   # report files that would change, do not modify

--check prints each unformatted file and exits with code 1:

Checking formatting...
[warn] src/a.js
[warn] src/b.ts
[warn] Code style issues found in 2 files. Run Prettier with --write to fix.

Exit code 0 means everything is formatted, 1 means at least one file would change, and 2 means Prettier itself failed, for example because of a syntax error or because a glob matched nothing ([error] No files matching the pattern were found). Put both commands in package.json so everyone runs exactly the same thing:

{
  "scripts": {
    "format": "prettier . --write",
    "format:check": "prettier . --check"
  }
}

Configuration: keep it small

Prettier reads .prettierrc (JSON or YAML), .prettierrc.json, prettier.config.js/.mjs/.cjs, or a "prettier" key in package.json. It searches from the file being formatted up to the project root, so a stray .prettierrc in a subfolder silently overrides the root one.

A realistic config is two or three lines:

{
  "singleQuote": true,
  "printWidth": 100
}

Things worth knowing about the defaults in Prettier 3:

  • trailingComma defaults to "all" (it was "es5" in Prettier 2). If you upgrade from 2.x and suddenly see commas after the last function parameter, this is why.
  • endOfLine defaults to "lf".
  • semi: true, tabWidth: 2, arrowParens: "always", bracketSpacing: true.
  • If an .editorconfig exists, Prettier maps indent_style, indent_size/tab_width, max_line_length and end_of_line onto its own options, unless .prettierrc sets them explicitly.

If you want comments in the config (JSON has none), use a JS config:

// prettier.config.mjs
/** @type {import("prettier").Config} */
export default {
  singleQuote: true,
  printWidth: 100,
  plugins: ["prettier-plugin-tailwindcss"], // plugins must be listed explicitly in v3
  overrides: [
    { files: "*.md", options: { proseWrap: "always" } },
  ],
};

The one rule that surprises everyone

Prettier generally ignores your original formatting, with one notable exception: object literals. If there is a newline between { and the first key, Prettier keeps the object expanded even if it would fit on one line.

// input
const a = {
  b: 1 }
const c = {  d: 1 }

// output
const a = {
  b: 1,
};
const c = { d: 1 };

This is intentional (it lets you keep config-like objects vertical), but it means Prettier output is not purely a function of the tree. Prettier 3.5 added objectWrap: "collapse" for teams that want the purely mechanical behavior.


.prettierignore

Since Prettier 3, the CLI reads both .gitignore and .prettierignore by default, and it always skips node_modules. So build output that is already git-ignored does not need to be listed twice. .prettierignore is for files that are committed but should not be reformatted:

# .prettierignore
pnpm-lock.yaml
package-lock.json
CHANGELOG.md          # generated by release tooling
src/generated/        # codegen output, overwritten on every build
public/vendor/
*.min.js

Lockfiles are the important entry. They are generated by the package manager, and reformatting them produces huge diffs and merge conflicts that the next npm install undoes anyway.

For a single block of code, use an ignore comment instead of excluding the whole file:

// prettier-ignore
const matrix = [
  1, 0, 0,
  0, 1, 0,
  0, 0, 1,
];

Prettier and ESLint: stop them fighting

ESLint historically shipped formatting rules (indent, quotes, comma-dangle, max-len, and so on). If both tools enforce formatting, they disagree: Prettier writes double quotes, ESLint’s quotes rule wants single quotes, and save-then-lint turns into a loop. ESLint deprecated its core formatting rules in v8.53, but popular shared configs and plugins (Airbnb, @stylistic, some framework presets) still turn such rules on.

The fix is eslint-config-prettier, which is not a plugin but a config that switches off every rule that conflicts with Prettier. It must come last, so it overrides whatever earlier configs enabled. With ESLint 9’s flat config:

// eslint.config.js
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import eslintConfigPrettier from "eslint-config-prettier";

export default [
  js.configs.recommended,
  ...tseslint.configs.recommended,
  eslintConfigPrettier, // last: turns off conflicting formatting rules
];

With the legacy .eslintrc format the equivalent is "extends": ["eslint:recommended", "prettier"], again with "prettier" last. The package also ships a small CLI (npx eslint-config-prettier src/index.ts) that reports rules in your resolved config that still conflict, which is useful after adding a new shared config.

What about eslint-plugin-prettier?

eslint-plugin-prettier runs Prettier as an ESLint rule and reports every difference as prettier/prettier errors. The old version of this article showed it together with a manual "plugins": ["prettier"] and "prettier/prettier": "error" entry, which is redundant: the plugin:prettier/recommended preset (or eslint-plugin-prettier/recommended in flat config) already registers the plugin, enables the rule and includes eslint-config-prettier.

The trade-off is that linting becomes slower (Prettier runs inside every lint pass) and the editor shows a red squiggle for every missing semicolon that format-on-save would fix anyway. The Prettier team’s own recommendation is to run Prettier and ESLint as separate steps. I only reach for the plugin when a project already has a single eslint --fix step everywhere and adding a second command is not an option.


Line endings: the “Delete ␍” error

The failure I have seen most often with Prettier is not about style at all. A Windows developer with Git’s core.autocrlf=true checks out the repository, Git converts every file to CRLF on disk, and then prettier --check reports every single file as unformatted, or, with eslint-plugin-prettier, the editor lights up with:

Delete `␍`  prettier/prettier

Prettier defaults to endOfLine: "lf" and sees the \r characters as formatting errors. Running --write “fixes” it locally, the files look modified in git status, and the problem comes back on the next checkout. Setting endOfLine: "auto" hides the symptom but lets mixed endings into the repository. The durable fix is to tell Git what the repository stores:

# .gitattributes
* text=auto eol=lf

After adding it, run git add --renormalize . once so existing files are normalized in the index. From then on Git checks out LF everywhere and Prettier, Git and every editor agree.


Editor integration

In VS Code, install the esbenp.prettier-vscode extension and set it as the formatter:

{
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "editor.formatOnSave": true
}

Committing this as .vscode/settings.json (plus an extensions.json recommendation) saves every new contributor the setup step. The extension uses the Prettier version installed in the project’s node_modules when there is one and falls back to its bundled copy otherwise, which is another reason to install Prettier locally: without it, the editor and CI can run different versions. If format-on-save does nothing, the extension’s output panel (“Prettier” in the Output dropdown) usually says why, for example that the file is ignored or that another formatter is set as the default for that language.

WebStorm and other JetBrains IDEs have built-in support under Settings, Languages & Frameworks, JavaScript, Prettier, with options to run on save and on reformat.


Enforce it: pre-commit hook and CI

Format-on-save is a convenience, not a guarantee; someone will always commit from a different editor. Two layers catch that.

Pre-commit, with Husky and lint-staged, formats only the files being committed:

npm install --save-dev husky lint-staged
npx husky init
echo "npx lint-staged" > .husky/pre-commit
{
  "lint-staged": {
    "*": "prettier --write --ignore-unknown"
  }
}

--ignore-unknown (-u) makes Prettier skip file types it has no parser for, so the catch-all * glob does not fail on images or .lock files. The Husky article covers why hooks sometimes do not run after a fresh clone.

CI is the real enforcement, because hooks can be skipped with git commit --no-verify:

name: Format
on: [push, pull_request]

jobs:
  prettier:
    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 format:check

Some teams add a job that runs --write and pushes a “style: format” commit back to the branch. It works for branches in the same repository, but the default GITHUB_TOKEN cannot push to pull requests from forks, and an automatic commit can surprise the author, who then has to pull before pushing again. Failing the check and letting the author run npm run format is simpler and more predictable.

On large repositories, prettier . --check --cache skips files that have not changed since the last run (the cache lives in node_modules/.cache/prettier by default), which is useful locally; in CI the cache usually does not survive between runs unless you persist it.


Adopting Prettier in an existing codebase

The switch is one large commit, and that commit is where most of the pain lives:

  1. Merge or rebase open branches first where possible, since the formatting commit will conflict with almost everything in flight.
  2. Commit the config, .prettierignore and .gitattributes together.
  3. Run npm run format and commit the result on its own, with no other changes, so it is easy to review (“only whitespace changed”) and easy to skip.
  4. Add that commit’s hash to .git-blame-ignore-revs and run git config blame.ignoreRevsFile .git-blame-ignore-revs. GitHub’s blame view reads this file automatically, so git blame keeps pointing at the real authors instead of the formatting commit.
  5. Turn on the CI check in the same PR so the codebase cannot drift back.

For branches that were still open, the cleanest recovery is to run the same pinned Prettier on the branch, commit, and then rebase; the formatting-only conflicts mostly resolve themselves once both sides are formatted identically.


When not to use Prettier

If you want a single fast tool for both linting and formatting and can live with slightly less mature support for some languages and plugins, Biome is the main alternative. Its formatter aims for close Prettier compatibility, so switching mostly produces small diffs. For languages Prettier does not cover (Python, Go, Rust), use the ecosystem’s formatter (ruff format or Black, gofmt, rustfmt); the same principles apply: pin the version, run it in CI, and keep the options minimal.


Prettier documentation, Prettier options, Integrating with linters, and the Prettier playground, which is the quickest way to see what a given option actually changes before committing it to the config.