Tooling

Linting and Formatting: ESLint and Prettier Setup

A complete guide to setting up ESLint and Prettier for Node.js projects covering configuration, editor integration, pre-commit hooks, and team workflows.

Linting and Formatting: ESLint and Prettier Setup

I have worked on teams where every pull request turned into a style debate. Tabs versus spaces, semicolons or not, trailing commas everywhere or nowhere. These conversations burn hours and produce nothing of value. The solution is simple: automate it. ESLint catches bugs and enforces coding patterns. Prettier formats your code so nobody has to think about whitespace ever again. Together they eliminate an entire category of wasted time, and they take about fifteen minutes to set up properly.

This guide walks through a complete ESLint and Prettier configuration for a Node.js Express project, from first install to pre-commit hooks that prevent bad code from ever reaching your repository.

Why Linting and Formatting Matter

Linting is not about being pedantic. It is about catching real bugs before they hit production. ESLint will flag variables you declared but never used, assignments in conditional expressions that were probably meant to be comparisons, and functions that sometimes return a value and sometimes do not. These are genuine mistakes that slip past even experienced developers during code review.

Formatting is different. Formatting is about consistency. When every file in a project uses the same indentation, the same quote style, and the same line length, the codebase reads like it was written by one person. Diffs become cleaner because formatting changes stop polluting your commit history. New developers onboard faster because the code is predictable.

The key insight is that these are two separate concerns. ESLint handles code quality — logical errors, bad practices, potential bugs. Prettier handles code style — whitespace, line breaks, bracket placement. Trying to use one tool for both jobs creates conflicts. Use each tool for what it does best.

Setting Up ESLint

Start by installing ESLint as a dev dependency:

npm install --save-dev eslint

Now create your configuration file. ESLint supports several formats, but I recommend .eslintrc.json for most projects because it is explicit and easy to read:

{
  "env": {
    "node": true,
    "es2020": true
  },
  "extends": [
    "eslint:recommended"
  ],
  "parserOptions": {
    "ecmaVersion": 2020
  },
  "rules": {
    "no-unused-vars": "warn",
    "no-console": "off",
    "eqeqeq": "error",
    "no-var": "off",
    "prefer-const": "off",
    "no-throw-literal": "error",
    "no-return-await": "warn",
    "no-shadow": "warn",
    "callback-return": "error",
    "handle-callback-err": "error",
    "no-path-concat": "error"
  }
}

A few things to notice here. The env block tells ESLint which global variables exist — setting node: true means require, module, __dirname, and process will not trigger undefined variable warnings. The parserOptions block sets the JavaScript version so ESLint knows which syntax is valid.

Understanding Rule Severity

Every ESLint rule has three levels:

  • "off" (or 0) — The rule is disabled completely
  • "warn" (or 1) — Violations show as warnings but do not cause ESLint to exit with an error code
  • "error" (or 2) — Violations show as errors and cause ESLint to exit with a non-zero code, which fails CI builds

I set no-unused-vars to "warn" because unused variables during development are normal — you declare something, write the implementation, and clean up later. Making this an error interrupts your flow. But eqeqeq gets "error" because using == instead of === is almost always a bug, and I want the build to break if someone does it.

For Node.js projects specifically, I turn off no-var and prefer-const because many Node.js codebases use var intentionally and there is nothing wrong with that. I keep no-console off because server-side logging through console.log is perfectly legitimate, unlike in browser code.

Extending Shared Configurations

The eslint:recommended config is a good baseline, but you might want something more opinionated. The Airbnb style guide is popular:

npm install --save-dev eslint-config-airbnb-base eslint-plugin-import
{
  "extends": [
    "eslint-config-airbnb-base"
  ],
  "rules": {
    "no-var": "off",
    "prefer-const": "off",
    "no-console": "off",
    "func-names": "off",
    "no-underscore-dangle": "off"
  }
}

When you extend a shared config, your local rules block overrides anything in the extended config. This lets you adopt a strict baseline and relax the specific rules that do not fit your project. I turn off no-underscore-dangle because Node.js conventions like _id in MongoDB documents and __dirname are perfectly fine.

Creating Custom Rules with Overrides

Sometimes you need different rules for different parts of your project. ESLint supports overrides for this:

{
  "extends": ["eslint:recommended"],
  "rules": {
    "no-console": "warn"
  },
  "overrides": [
    {
      "files": ["tests/**/*.js", "**/*.test.js"],
      "env": {
        "mocha": true
      },
      "rules": {
        "no-unused-expressions": "off",
        "no-console": "off"
      }
    },
    {
      "files": ["scripts/**/*.js"],
      "rules": {
        "no-console": "off",
        "no-process-exit": "off"
      }
    }
  ]
}

Test files often use assertion libraries like Chai that rely on unused expressions (expect(value).to.be.true), so disabling that rule in test files makes sense. Build scripts and CLI tools legitimately use process.exit(), so that rule gets turned off in the scripts directory.

Setting Up Prettier

Install Prettier:

npm install --save-dev prettier

Create a .prettierrc file in your project root:

{
  "semi": true,
  "singleQuote": true,
  "tabWidth": 2,
  "trailingComma": "none",
  "printWidth": 100,
  "bracketSpacing": true,
  "arrowParens": "always",
  "endOfLine": "lf"
}

These are the settings I use on every project. Semicolons always, single quotes for strings, two-space indentation, no trailing commas, and a 100-character line width. The endOfLine setting is critical for teams with developers on different operating systems — set it to "lf" and let Prettier normalize line endings so you never see a diff full of CRLF changes.

You can also create a .prettierignore file to keep Prettier from touching files it should not format:

node_modules/
dist/
build/
coverage/
package-lock.json
*.min.js
*.min.css

Making ESLint and Prettier Work Together

This is where most people get tripped up. ESLint has its own formatting rules (like indent, quotes, semi), and those will conflict with Prettier. The solution is two packages:

npm install --save-dev eslint-config-prettier eslint-plugin-prettier

eslint-config-prettier disables all ESLint rules that would conflict with Prettier. It turns off every formatting-related rule so ESLint only handles code quality.

eslint-plugin-prettier runs Prettier as an ESLint rule. When Prettier would reformat something, ESLint reports it as a linting error. This means you get formatting violations in the same output as linting violations.

Update your .eslintrc.json:

{
  "env": {
    "node": true,
    "es2020": true
  },
  "extends": [
    "eslint:recommended",
    "plugin:prettier/recommended"
  ],
  "parserOptions": {
    "ecmaVersion": 2020
  },
  "plugins": ["prettier"],
  "rules": {
    "prettier/prettier": "warn",
    "no-unused-vars": "warn",
    "eqeqeq": "error",
    "no-var": "off",
    "prefer-const": "off",
    "no-console": "off",
    "no-throw-literal": "error",
    "no-shadow": "warn",
    "callback-return": "error",
    "handle-callback-err": "error",
    "no-path-concat": "error"
  }
}

The "plugin:prettier/recommended" extension must come last in the extends array. It enables the Prettier plugin and disables conflicting ESLint rules in one step. I set prettier/prettier to "warn" instead of "error" because I do not want formatting issues to block development — they get auto-fixed anyway.

ESLint for Node.js: eslint-plugin-node

For Node.js-specific rules, install the node plugin:

npm install --save-dev eslint-plugin-n
{
  "extends": [
    "eslint:recommended",
    "plugin:n/recommended",
    "plugin:prettier/recommended"
  ],
  "rules": {
    "n/no-missing-require": "error",
    "n/no-unpublished-require": "off",
    "n/no-unsupported-features/es-syntax": "off",
    "n/no-process-exit": "warn",
    "n/exports-style": ["error", "module.exports"]
  }
}

The n/no-missing-require rule catches typos in require() calls by checking whether the module actually exists. That alone justifies installing this plugin — a misspelled package name in a require statement will blow up at runtime, and this catches it at lint time.

NPM Scripts for Linting

Add these scripts to your package.json:

{
  "scripts": {
    "lint": "eslint .",
    "lint:fix": "eslint . --fix",
    "format": "prettier --write .",
    "format:check": "prettier --check .",
    "validate": "npm run lint && npm run format:check"
  }
}

The lint:fix script auto-fixes everything ESLint can fix automatically — unused imports, missing semicolons, and all Prettier formatting issues (since we run Prettier through ESLint). The validate script is what your CI pipeline should run. It checks for both linting errors and formatting violations without modifying any files.

Create an .eslintignore file so ESLint skips files it should not process:

node_modules/
dist/
build/
coverage/
public/vendor/
*.min.js

Editor Integration: VS Code

Create a .vscode/settings.json file in your project root so every team member gets the same editor behavior:

{
  "editor.formatOnSave": true,
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": "explicit"
  },
  "eslint.validate": ["javascript", "json"],
  "prettier.requireConfig": true,
  "files.eol": "\n",
  "editor.tabSize": 2,
  "files.trimTrailingWhitespace": true,
  "files.insertFinalNewline": true
}

With this configuration, every time you save a file, VS Code runs Prettier to format it and ESLint to fix auto-fixable issues. The prettier.requireConfig setting ensures Prettier only runs in projects that have a .prettierrc file, so it does not reformat random files on your machine.

Commit this .vscode/settings.json file to your repository. Some people argue editor settings should not be in source control, but I disagree. Consistent editor behavior across the team prevents formatting churn and reduces friction.

Pre-Commit Hooks with Husky and lint-staged

This is the most important part of the setup. Editor integration is nice, but it is optional — a developer might use Vim, or they might disable format-on-save. Pre-commit hooks are mandatory. They run before every commit and reject code that does not pass linting.

npm install --save-dev husky lint-staged
npx husky init

The npx husky init command creates a .husky/ directory and adds a prepare script to your package.json. Now create the pre-commit hook:

echo "npx lint-staged" > .husky/pre-commit

Add the lint-staged configuration to your package.json:

{
  "lint-staged": {
    "*.js": [
      "eslint --fix",
      "prettier --write"
    ],
    "*.{json,md,yml,yaml}": [
      "prettier --write"
    ]
  }
}

The beauty of lint-staged is that it only runs on files that are staged for commit, not the entire project. This keeps the pre-commit hook fast even in large codebases. When you stage a file and commit, lint-staged runs ESLint and Prettier on just that file. If ESLint finds an error it cannot auto-fix, the commit is rejected. If it finds issues it can fix, it fixes them and includes the fixes in the commit.

Complete Working Example

Here is the full setup for a Node.js Express project. Start with a fresh project:

mkdir my-api && cd my-api
npm init -y
npm install express
npm install --save-dev eslint prettier eslint-config-prettier eslint-plugin-prettier eslint-plugin-n husky lint-staged
npx husky init
echo "npx lint-staged" > .husky/pre-commit

package.json (relevant sections):

{
  "scripts": {
    "start": "node app.js",
    "dev": "nodemon app.js",
    "lint": "eslint .",
    "lint:fix": "eslint . --fix",
    "format": "prettier --write .",
    "format:check": "prettier --check .",
    "validate": "npm run lint && npm run format:check",
    "prepare": "husky"
  },
  "lint-staged": {
    "*.js": [
      "eslint --fix",
      "prettier --write"
    ],
    "*.{json,md,yml,yaml}": [
      "prettier --write"
    ]
  }
}

.eslintrc.json:

{
  "env": {
    "node": true,
    "es2020": true
  },
  "extends": [
    "eslint:recommended",
    "plugin:n/recommended",
    "plugin:prettier/recommended"
  ],
  "parserOptions": {
    "ecmaVersion": 2020
  },
  "plugins": ["prettier"],
  "rules": {
    "prettier/prettier": "warn",
    "no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
    "no-var": "off",
    "prefer-const": "off",
    "no-console": "off",
    "eqeqeq": "error",
    "no-throw-literal": "error",
    "no-shadow": "warn",
    "no-return-await": "warn",
    "callback-return": "error",
    "handle-callback-err": "error",
    "no-path-concat": "error",
    "n/no-unpublished-require": "off",
    "n/no-unsupported-features/es-syntax": "off",
    "n/exports-style": ["error", "module.exports"]
  },
  "overrides": [
    {
      "files": ["tests/**/*.js", "**/*.test.js"],
      "env": { "mocha": true },
      "rules": {
        "no-unused-expressions": "off"
      }
    }
  ]
}

.prettierrc:

{
  "semi": true,
  "singleQuote": true,
  "tabWidth": 2,
  "trailingComma": "none",
  "printWidth": 100,
  "bracketSpacing": true,
  "endOfLine": "lf"
}

.eslintignore:

node_modules/
dist/
coverage/

.prettierignore:

node_modules/
dist/
coverage/
package-lock.json

.vscode/settings.json:

{
  "editor.formatOnSave": true,
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": "explicit"
  },
  "prettier.requireConfig": true,
  "files.eol": "\n"
}

app.js (sample Express app):

var express = require('express');
var app = express();
var port = process.env.PORT || 3000;

app.use(express.json());

app.get('/', function (req, res) {
  res.json({ message: 'API is running', status: 'ok' });
});

app.get('/health', function (req, res) {
  res.json({
    uptime: process.uptime(),
    timestamp: Date.now(),
    status: 'healthy'
  });
});

app.use(function (err, _req, res, _next) {
  console.error(err.stack);
  res.status(500).json({ error: 'Internal server error' });
});

app.listen(port, function () {
  console.log('Server running on port ' + port);
});

module.exports = app;

Notice the _req and _next parameters prefixed with underscores — that argsIgnorePattern: "^_" rule in the ESLint config tells ESLint to ignore function arguments that start with an underscore. Express error handlers require four parameters, but you do not always use all of them. This pattern documents that the parameter is intentionally unused.

Migrating to Flat Config Format

ESLint v9 introduced the flat config format using eslint.config.js instead of .eslintrc.* files. The legacy format still works but is deprecated. Here is what the migration looks like:

var js = require('@eslint/js');
var prettier = require('eslint-config-prettier');
var nodePlugin = require('eslint-plugin-n');

module.exports = [
  js.configs.recommended,
  nodePlugin.configs['flat/recommended'],
  prettier,
  {
    languageOptions: {
      ecmaVersion: 2020,
      sourceType: 'commonjs',
      globals: {
        require: 'readonly',
        module: 'readonly',
        exports: 'readonly',
        __dirname: 'readonly',
        __filename: 'readonly',
        process: 'readonly',
        console: 'readonly',
        Buffer: 'readonly',
        setTimeout: 'readonly',
        setInterval: 'readonly',
        clearTimeout: 'readonly',
        clearInterval: 'readonly'
      }
    },
    rules: {
      'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
      'no-console': 'off',
      'eqeqeq': 'error',
      'no-throw-literal': 'error'
    }
  },
  {
    files: ['tests/**/*.js'],
    languageOptions: {
      globals: {
        describe: 'readonly',
        it: 'readonly',
        before: 'readonly',
        after: 'readonly',
        beforeEach: 'readonly',
        afterEach: 'readonly'
      }
    }
  },
  {
    ignores: ['node_modules/', 'dist/', 'coverage/']
  }
];

The flat config is more explicit. Instead of env: { node: true } magically injecting globals, you list them yourself. It is more verbose, but there is no ambiguity about what is happening. I would recommend new projects start with flat config, but there is no rush to migrate existing projects — the legacy format will be supported for a long time.

Performance Optimization for Large Codebases

On projects with hundreds or thousands of JavaScript files, ESLint can become slow. A few strategies help:

Cache results. Add --cache to your lint script:

{
  "scripts": {
    "lint": "eslint --cache .",
    "lint:fix": "eslint --cache --fix ."
  }
}

ESLint stores results in .eslintcache and only re-lints files that have changed. Add .eslintcache to your .gitignore.

Limit the scope. Do not lint files you did not write. Third-party code in vendor/ directories, generated code, and minified bundles should all be in .eslintignore.

Use lint-staged in CI. For pull request checks, you can lint only the files changed in the PR rather than the entire codebase. This dramatically reduces CI time on large projects.

Parallelize. ESLint does not natively support parallel execution, but you can split files across multiple ESLint processes using tools or custom scripts that divide the file list.

Sharing Configuration Across Teams

If your organization has multiple Node.js projects, extract your ESLint and Prettier configs into a shared npm package:

mkdir eslint-config-mycompany
cd eslint-config-mycompany
npm init --scope=@mycompany

Create an index.js that exports your configuration:

module.exports = {
  env: {
    node: true,
    es2020: true
  },
  extends: ['eslint:recommended', 'plugin:prettier/recommended'],
  rules: {
    'no-unused-vars': 'warn',
    'eqeqeq': 'error',
    'no-console': 'off',
    'no-var': 'off',
    'prefer-const': 'off'
  }
};

Publish it to your private registry, then in each project:

npm install --save-dev @mycompany/eslint-config
{
  "extends": ["@mycompany"]
}

Every project gets the same baseline. Individual projects can still override rules in their own .eslintrc.json where needed.

Common Issues and Troubleshooting

ESLint and Prettier conflict on a rule. This almost always means eslint-config-prettier is not listed last in your extends array. It must come after every other config to properly disable conflicting rules. Put "plugin:prettier/recommended" at the very end.

ESLint reports errors in node_modules. Make sure you have an .eslintignore file that includes node_modules/. Alternatively, if using flat config, include { ignores: ['node_modules/'] } in your config array.

Pre-commit hook is not running. Check that the .husky/pre-commit file is executable. Run chmod +x .husky/pre-commit on Unix systems. Also verify that husky was initialized properly — there should be a "prepare": "husky" script in your package.json.

ESLint is extremely slow. Enable caching with --cache. Check that you are not linting node_modules or other large directories. Run DEBUG=eslint:* npx eslint . to see detailed timing information and identify which rules or files are slow.

lint-staged modifies files but the changes are not included in the commit. This happened with older versions of lint-staged. Make sure you are on v13 or later, which handles re-staging automatically. If files are still not being included, check that your lint-staged config uses an array of commands rather than a single string.

Best Practices

  1. Run the full lint suite in CI. Editor integration and pre-commit hooks are developer conveniences. CI is the enforcement mechanism. Never merge a pull request that has not passed npm run validate.

  2. Do not disable rules with inline comments unless you have a reason. Every // eslint-disable-next-line should have a comment explaining why the rule is being suppressed. If you find yourself disabling the same rule repeatedly, change the rule configuration instead.

  3. Format the entire codebase once and commit it separately. When you first add Prettier to an existing project, run npx prettier --write . and commit that as a single formatting-only commit. This keeps the formatting changes out of your feature branches and makes git blame easier to work with. Use git blame --ignore-rev to skip that commit in blame output.

  4. Keep ESLint and Prettier configs in the project root. Do not nest them in subdirectories unless you genuinely need different rules for different parts of the project. One config per project keeps things simple.

  5. Update your linting dependencies regularly. ESLint releases new rules that catch real bugs. Pin major versions in package.json but let minor and patch versions update freely. Run npm outdated monthly and review what has changed.

  6. Treat warnings seriously. Warnings exist so you can see issues without blocking your workflow, but they should trend toward zero. If your project has 200 warnings that nobody is fixing, they become noise and developers stop reading ESLint output entirely. Either fix the warnings or change the rule to "off" — do not let them accumulate.

  7. Document any non-obvious rule choices. If you turn off a commonly-enabled rule or set something stricter than the default, add a comment in your config explaining why. Future team members will thank you.

References

Powered by Contentful