Linting, Formatting & Tooling

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences of Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out; versions and stability levels were checked against the official documentation on 2026-10-04, and every example below was run on all three lines unless the text says otherwise. Node.js does not ship a linter, a formatter or a bundler, so this page is about the small set of external tools that almost every project adds, and about how much of the rest the runtime now covers by itself. On the Node side the command-line interface (node --check, node --test, node --watch, node --env-file) is Stability 2 (Stable), node --run carries no stability index in the CLI reference. On the ecosystem side the tools below are versioned independently of Node.js: ESLint 10.12.0, Prettier 3.9.9, esbuild 0.28.2, Rolldown 1.2.12, Husky 9.1.7, lint-staged 17.6.0, globals 17.13.0 and eslint-plugin-n 18.4.1 were the latest releases on 2026-10-04 and are what the examples were run with. The running scenario is the Bookshelf application described in Introduction & Architecture. This page is the Node-specific view; the language-level treatment of the same tools is in Static Analysis & Formatting and JavaScript bundling and publishing.

The stability and version map

Table 1. Tools and APIs covered on this page
Tool or API Stability / status (2026-10-04) Version notes

node --check, node --test, node --watch

2 Stable (command-line interface)

--watch has been stable since Node 22.0 (20.13). The test runner is covered in The Test Runner.

node --env-file, --env-file-if-exists

No longer experimental

Stable since v24.10.0 and v22.21.0 (added in 20.6; --env-file-if-exists in 22.9).

node --run

No stability index in the CLI reference

Added in v22.0.0. NODE_RUN_SCRIPT_NAME and NODE_RUN_PACKAGE_JSON_PATH since v22.3.0. Running it without a name lists the scripts since v26.9.0.

node --enable-source-maps

Stable (command-line interface)

Maps stack traces of bundled or transpiled code back to the original files; used in the bundling section.

ESLint

Third-party, 10.12.0

Flat config is the only format since v10.0.0 (the default since v9). Requires Node `^20.19.0

^22.13.0

>=24`.

Prettier

Third-party, 3.9.9

Declares >=14 as the supported Node range; the Node 22, 24 and 26 runs below all passed.

esbuild

Third-party, 0.28.2

Still a 0.x version number, so minor releases may carry breaking changes. Declares >=18.

Rolldown

Third-party, 1.2.12

Rollup-compatible bundler written in Rust. Requires Node `^20.19.0

>=22.12.0`.

Husky and lint-staged

Third-party, 9.1.7 and 17.6.0

lint-staged 17 declares >=22.22.1, which is why Node 22 needs a recent patch release for it.

What Node.js already covers

A decade ago a Node project started with a task runner, a file watcher, an environment loader and a transpiler. The runtime now provides the first three, and with type stripping a good part of the fourth, so the remaining external tools are the ones that do analysis (a linter), layout (a formatter) and packaging (a bundler, when you need one).

Table 2. Which tool a modern Node project needs, and which built-in replaces the old one
Job Built into Node.js Still an external tool

Restart on change

node --watch (Stable since 22.0), see Running Scripts, the CLI & the REPL

Not needed in most projects; nodemon is the older answer.

Load a .env file

node --env-file=.env (stable since 24.10 and 22.21), see CLI Options, Environment & Config File

Not needed; a loader package is only for older runtimes.

Run the scripts of package.json

node --run (since 22.0)

npm run when you need pre and post scripts, workspaces or npm’s own variables.

Run tests

node --test, see The Test Runner

A third-party runner when you want its features.

Syntax check

node --check file.js

The linter, which finds far more than syntax errors.

Run TypeScript

Type stripping, see TypeScript in Node.js

tsc for type checking and declaration files.

Find bugs and enforce conventions

Nothing

ESLint.

Consistent layout

Nothing

Prettier (or ESLint rules, which is discouraged for layout).

Single-file deployable

Single executable applications, see Single Executable Applications

esbuild or Rolldown for the bundle step.

ESLint with a flat config for Node

ESLint reads its configuration from an eslint.config.js (or .mjs, .cjs) module that exports an array of configuration objects; in ESLint 10 the old .eslintrc format is no longer supported, and the configuration file is looked up starting from the directory of each linted file rather than from the current directory. Node.js is not a browser, and ESLint knows no environment by itself: the language options give it the syntax rules, and the global variables of Node have to be listed so that process or Buffer are not reported as undefined. The globals package provides those lists. The configuration below is the one used for the Bookshelf project in this section.

eslint.config.js for an ES module Node project (ESLint 10, globals 17)
import { defineConfig, globalIgnores } from 'eslint/config';
import js from '@eslint/js';
import globals from 'globals';
import prettier from 'eslint-config-prettier/flat';

export default defineConfig([
  globalIgnores(['dist/', 'coverage/']),
  {
    files: ['**/*.js', '**/*.mjs'],
    plugins: { js },
    extends: ['js/recommended'],
    languageOptions: {
      sourceType: 'module',
      // ES modules have no require, module, exports, __dirname or __filename
      globals: { ...globals.nodeBuiltin },
    },
    rules: {
      'no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
    },
  },
  {
    files: ['**/*.cjs'],
    languageOptions: { sourceType: 'commonjs', globals: { ...globals.node } },
  },
  prettier,
]);

Four decisions in this file are specific to Node:

  • sourceType follows the module system. ESLint documents module as the default for .js and .mjs, and commonjs (top-level function scope, non-strict mode) for code that uses require(). A project that mixes both gets one configuration object per extension, as above.

  • globals.node versus globals.nodeBuiltin. Measured with globals 17.13.0: globals.node has 81 names and includes the CommonJS wrapper variables require, module, exports, dirname and filename; globals.nodeBuiltin has 76 names and leaves them out, while both contain process, fetch, URLPattern and structuredClone. In an ES module those five names do not exist at runtime, so nodeBuiltin is the right set: ESLint then reports a leftover __dirname as the bug it is (see the run below). Use globals.node only for the .cjs objects.

  • globalIgnores() keeps generated code out of the lint run. Linting a bundle produced by Rolldown from a file that imported Express, in an earlier run of this project, produced 135 errors, none of them in code the project owns, which is why dist/ and similar folders belong in the ignore list.

  • @eslint/js recommended changes between ESLint majors: ESLint 10 added no-unassigned-vars, no-useless-assignment and preserve-caught-error to it, so upgrading can surface new findings in unchanged code.

The next run lints a file with a deliberately sloppy body next to the real Bookshelf sources, with the project’s own unused-variable convention (an underscore prefix) applied:

Running ESLint on a file that leaks CommonJS habits into an ES module
$ cat src/bad.js
import { readFileSync } from "node:fs";
const unused = 1;
export function load(path) {
  return process.env.HOME + __dirname + undefinedThing;
}
$ npx eslint src/bad.js
src/bad.js
  1:10  error  'readFileSync' is defined but never used. Allowed unused vars must match /^_/u     no-unused-vars
  2:7   error  'unused' is assigned a value but never used. Allowed unused vars must match /^_/u  no-unused-vars
  3:22  error  'path' is defined but never used. Allowed unused args must match /^_/u             no-unused-vars
  4:29  error  '__dirname' is not defined                                                         no-undef
  4:41  error  'undefinedThing' is not defined                                                    no-undef

✖ 5 problems (5 errors, 0 warnings)
$ echo $?
1

The exit code is what a CI job or a git hook looks at: 1 for lint errors, and 0 when the run is clean. The same configuration and the same run gave the same result on Node 26.10.0, 24.21.0 and 22.23.3.

Node-aware rules with eslint-plugin-n

The js/recommended rules know nothing about Node. The community plugin eslint-plugin-n adds rules that do, such as whether a built-in is available in the Node range your package.json declares in engines, or whether process.exit() should be avoided. Its flat presets are flat/recommended-module and flat/recommended-script.

Adding eslint-plugin-n to the first configuration object
import n from 'eslint-plugin-n';

// inside defineConfig([...]), replacing extends: ['js/recommended']
// extends: ['js/recommended', n.configs['flat/recommended-module']],

Source: eslint-plugin-n.

With "engines": { "node": ">=22" } in package.json this reported five problems on the Bookshelf sources. One is a legitimate finding (process.exit() in the command-line tool). The other four are what the plugin’s own data says about the supported range, and are observed behaviour of eslint-plugin-n 18.4.1, not of Node.js: it still calls node:sqlite and import.meta.main "experimental" although the Node.js 26 documentation lists node:sqlite as Stability 1.2 (Release candidate) with no flag since 23.4 and 22.13, and import.meta.main as available since v24.2.0 and v22.18.0; and it flags util.styleText as unsupported before 23.5.0 even though it was backported to 22.13.0, which is inside a >=22.13 range (raising engines to >=22.18 did not change that finding). Treat such reports as prompts to check the Node.js documentation, not as proof; the stability index of every API on that page is the reference.

TypeScript and the ESLint config file

An eslint.config.ts is supported: for Node.js ESLint needs the optional dev dependency jiti at version 2.2.0 or later (ESLint 10 dropped older versions), or, as an Experimental alternative, Node’s own type stripping with npx --node-options='--experimental-strip-types' eslint --flag unstable_native_nodejs_ts_config. This was taken from the ESLint documentation and was not run here. Linting TypeScript sources is a separate concern handled by the typescript-eslint packages; see TypeScript in Node.js and tsconfig and compiler options.

Prettier

Prettier owns layout: quotes, wrapping, trailing commas, line width. It deliberately has few options, so the configuration file is short. A Node project written as ES modules can use a prettier.config.js that exports the options; .prettierignore uses .gitignore syntax.

prettier.config.js and .prettierignore
/** @type {import("prettier").Config} */
export default {
  singleQuote: true,
  trailingComma: 'all',
  printWidth: 100,
};
dist
coverage
package-lock.json

prettier --check is read-only and sets a non-zero exit code when a file would change, so it is the CI form; prettier --write rewrites files and is the editor and hook form. Running them on the Bookshelf sources, as first written with double quotes and a narrower layout:

Checking, then fixing, the layout
$ npx prettier --check .
Checking formatting...
[warn] eslint.config.js
[warn] package.json
[warn] prettier.config.js
[warn] src/cli.js
[warn] src/csv.js
[warn] src/pool.js
[warn] src/server.js
[warn] test/app.test.js
[warn] Code style issues found in 8 files. Run Prettier with --write to fix.
$ npx prettier --write . > /dev/null
$ npx prettier --check .
Checking formatting...
All matched files use Prettier code style!

Source: Prettier: CLI.

Keep ESLint out of the formatting business. Style rules that ESLint could also enforce (quotes, semicolons, indentation) would fight Prettier over the same lines; the eslint-config-prettier package, imported as eslint-config-prettier/flat in the first configuration, turns those rules off. It must be the last element of the array so that nothing re-enables them afterwards. ESLint then does logic, Prettier does layout, and a file can satisfy both at once. The same division is described for plain JavaScript projects in Static Analysis & Formatting.

Bundling a Node.js application, and when not to

A browser needs a bundle because it downloads files one at a time over a network. A Node.js server reads its files from disk and resolves node_modules at startup, so the default answer for a server is: do not bundle. Run the source, as the Bookshelf scripts do. Bundling a Node application is a deliberate choice for specific reasons.

Table 3. Bundling a Node.js application: reasons for and against
Bundle when Reason

You ship one file or a small image

A single dist/server.js plus a source map is easier to copy into a container layer, a serverless function or a single executable application than a whole node_modules tree (see Single Executable Applications).

Startup time matters

Fewer files to resolve and read on cold start, which matters most for short-lived processes.

Your sources need a build step anyway

A compile step already exists for JSX, a non-standard syntax or TypeScript features that type stripping does not cover.

Table 4. Bundling: reasons not to
Do not bundle when Reason

A dependency is a native addon

A compiled .node file cannot be inlined; keep the package external (see Native Addons, WebAssembly & FFI).

Code builds paths from import.meta.url, __dirname or reads files next to itself

The location changes when everything is moved into one file in another folder.

The dependency uses dynamic require() or optional requires

A bundler cannot follow computed module names; it can break at run time, as shown below.

You want debuggable stack traces without extra flags

Stack traces point into the bundle unless source maps are generated and enabled.

The project is a library

Consumers of a published package want separate files they can de-duplicate and tree-shake; see Workspaces & Publishing.

esbuild

esbuild bundles a Node entry point with one command. Three options matter for Node: --platform=node (keeps the node: built-ins external and picks Node-appropriate resolution), --format=esm (to match "type": "module"), and --packages=external (leave everything in node_modules as a normal import, so the bundle holds only your own code).

A package.json script that bundles the Bookshelf server, and its run
{
  "scripts": {
    "build": "esbuild src/server.js --bundle --platform=node --format=esm --packages=external --sourcemap --outfile=dist/server.js"
  }
}
$ node --run build

  dist/server.js      2.1kb
  dist/server.js.map  3.8kb

⚡ Done in 1ms
$ PORT=0 node dist/server.js
listening 50263

Source: esbuild: API (platform, packages, sourcemap).

The bundle starts and serves exactly like node src/server.js, including the import.meta.main check that makes the file both a module and a program. The same bundle built and ran on Node 26.10.0, 24.21.0 and 22.23.3 (the 22 run printed the node:sqlite experimental warning that 22 prints for the source too).

Source maps show the difference in an error. With the map generated, a stack trace names the bundle until --enable-source-maps is passed:

A thrown error, without and with --enable-source-maps
$ node dist/boom.js
Error: boom
    at fail (file:///.../dist/boom.js:3:9)
$ node --enable-source-maps dist/boom.js
Error: boom
    at fail (/.../src/boom.js:2:9)

Source: Node.js documentation: Command-line API (--enable-source-maps). See also Debugging.

Without --packages=external, esbuild inlines dependencies, and that is where the pitfall of the table above appears. Express 5.2.1, the version that resolved on 2026-10-04, was bundled as ESM:

Bundling a CommonJS dependency into an ES module output, and two ways out
$ npx esbuild src/app-express.js --bundle --platform=node --format=esm --outfile=dist/ex-esm.js
  dist/ex-esm.js  1.1mb ⚠️
$ node dist/ex-esm.js
Error: Dynamic require of "tty" is not supported
$ npx esbuild src/app-express.js --bundle --platform=node --format=cjs --outfile=dist/ex-cjs.cjs
$ node dist/ex-cjs.cjs
listening 50152
$ npx esbuild src/app-express.js --bundle --platform=node --format=esm --outfile=dist/ex-esm2.js \
    --banner:js="import { createRequire as __cr } from 'node:module'; const require = __cr(import.meta.url);"
$ node dist/ex-esm2.js
listening 50153

Source: esbuild: API (format, banner) and Node.js documentation: Modules: node:module (module.createRequire()).

The error is a bundler limitation, not a Node.js bug: inside the bundle an inlined CommonJS package asks for the built-in tty with require, which does not exist in an ES module. The three outputs above are the alternatives: stop bundling that package (the --packages=external form), emit CommonJS, or give the ES bundle a require made with module.createRequire(). The first is the simplest, and 1.1 MB of Express in the bundle bought nothing for a server that has node_modules on disk.

Rolldown

Rolldown is a bundler written in Rust with a Rollup-compatible API; a configuration file exports an object, and the Node-specific part is again the platform.

rolldown.config.js for the same entry point
import { defineConfig } from 'rolldown';

export default defineConfig({
  input: 'src/server.js',
  platform: 'node',
  output: { dir: 'dist-rd', format: 'esm', sourcemap: true },
});
$ npx rolldown -c rolldown.config.js
<DIR>/server.js.map  asset │ size: 3.69 kB
<DIR>/server.js      chunk │ size: 2.12 kB

✔ rolldown v1.2.12 Finished in 9.00 ms
$ PORT=0 node dist-rd/server.js
listening 50171

Rolldown’s defaults differ from esbuild’s in one observable way. The same Express entry point bundled with npx rolldown src/app-express.js -p node -f esm -o dist-rd/ex.js produced a 915.60 kB file that ran and printed listening on the first try, with none of the dynamic-require error above. That is observed behaviour of Rolldown 1.2.12 for this one program; it does not mean every dependency bundles cleanly, and the argument for external packages on a server is unchanged.

Packages with install scripts

esbuild, like several tools in this chapter, ships a postinstall script. With npm 11.19.1 (the npm that Node 26.10.0 bundles) the install printed that one package had install scripts "not yet covered by allowScripts" and suggested npm install-scripts approve <pkg>. The npm documentation for the command states that dependency install scripts are blocked by default unless they are listed in the allowScripts field of package.json. In this project that made no difference to the tool, because esbuild’s native binary arrives as the optional dependency @esbuild/darwin-arm64 (the one for this machine), not through the script; for other packages, decide explicitly with npm install-scripts ls, approve and deny. Lifecycle scripts are the subject of npm & Package Management.

Git hooks

A git hook is an executable that git runs at a moment such as pre-commit. Its value is a fast, local "no" before a broken file reaches a pull request, and its limit is that it is local: git commit --no-verify skips it, and a fresh clone has no hooks until something installs them. The authoritative check is therefore the same commands in CI (see Production Deployment); the hook is a courtesy. Two approaches were run.

Husky with lint-staged

Husky 9 stores the hooks as files in a .husky/ folder that is committed with the project, and its prepare script points git at that folder (core.hooksPath became .husky/_ in this run) every time someone runs npm install. lint-staged runs commands against only the files that are staged, which keeps the hook fast on a large repository.

package.json (excerpt) and .husky/pre-commit
{
  "scripts": {
    "prepare": "husky"
  },
  "lint-staged": {
    "*.{js,mjs}": ["eslint --max-warnings=0", "prettier --check"],
    "*.{json,md,yml}": "prettier --check"
  }
}
npx lint-staged

A commit of a file with an unused variable and missing layout is refused, and the working tree is put back as it was:

A refused commit (lint-staged 17.6.0 and Husky 9.1.7; the output has two "Done" status lines trimmed and the path shortened)
git add src/sloppy.js && git commit -m bad
⋯ Backing up original state…
⋯ Running tasks for staged files…
    *.{js,mjs} — 1 file
      ⋯ eslint --max-warnings=0
      ⋯ prettier --check

✖ eslint --max-warnings=0
↓ prettier --check

✖ Failed to run tasks for staged files!
↓ Skipped staging changes from tasks…
⋯ Reverting to original state because of errors…
✔ Done reverting to original state!
⋯ Cleaning up temporary files…

✖ eslint --max-warnings=0:

src/sloppy.js
  1:7  error  'x' is assigned a value but never used. Allowed unused vars must match /^_/u  no-unused-vars

✖ 1 problem (1 error, 0 warnings)
husky - pre-commit script failed (code 1)

The same check ran cleanly on a conforming commit (both tasks passed, "Done running tasks for staged files!"), and the failing commit left no new entry in git log. lint-staged 17 requires Node >=22.22.1, so a team on the Node 22 line must be on a recent patch release.

A dependency-free alternative

Git itself can use a committed folder of hooks: git config core.hooksPath .githooks makes git look there. A hook that runs the project’s own check script needs no extra package.

A hook without Husky
$ mkdir .githooks
$ printf '#!/bin/sh\nnode --run check\n' > .githooks/pre-commit
$ chmod +x .githooks/pre-commit
$ git config core.hooksPath .githooks
$ git commit -m "t2"
Checking formatting...
All matched files use Prettier code style!

Source: Git: githooks and Git: git-config (core.hooksPath).

This runs the whole check on every commit, which is simple and slower than lint-staged on a big project, and the git config line has to be repeated in every clone, which is exactly what Husky’s prepare script automates.

Replacing task runners with node --run

Gulp, Grunt and the heavier npm-script wrappers were built to run a list of commands in order; the scripts object of package.json does that already, and node --run runs it without starting the npm CLI. A Bookshelf package.json with the scripts of this page:

The scripts section used throughout this page
{
  "scripts": {
    "start": "node --env-file-if-exists=.env src/server.js",
    "dev": "node --watch src/server.js",
    "lint": "eslint .",
    "format": "prettier --write .",
    "format:check": "prettier --check .",
    "test": "node --test",
    "build": "esbuild src/server.js --bundle --platform=node --format=esm --packages=external --sourcemap --outfile=dist/server.js",
    "check": "node --run lint && node --run format:check && node --run test"
  }
}
Running scripts with node --run
$ node --run check; echo exit=$?
...
exit=0
$ node --run lint -- --max-warnings=0 --format json | head -c 60
[{"filePath":"/.../src/cli.js"
$ node --run
Available scripts are:
  start: node --env-file-if-exists=.env src/server.js
  dev: node --watch src/server.js
  lint: eslint .
  format: prettier --write .
  format:check: prettier --check .
  test: node --test
  build: esbuild src/server.js --bundle --platform=node --format=esm --packages=external --sourcemap --outfile=dist/server.js
  check: node --run lint && node --run format:check && node --run test

Everything above ran on Node 26.10.0, 24.21.0 and 22.23.3, with one exception: the bare node --run listing only exists from Node 26.9; on 24.21 and 22.23 it prints an error and exits non-zero, as described in Running Scripts, the CLI & the REPL, where the complete comparison with npm run is measured. The limits that matter for a tooling script:

  • Arguments after -- are appended to the end of the script’s command. The format:check script above already ends in .; passing a file after -- therefore checks . and that file, so keep the script free of a path when you want to pass one.

  • node --run does not run pre and post scripts, so a prebuild hook is silently skipped; call the steps explicitly, as check does.

  • --env-file is not applied to the command that --run starts (the CLI reference says so); put the flag inside the script, as start does.

  • node --run runs commands through the shell of the platform, so write portable commands. The && chaining above worked, but parallel execution and file globbing come from the shell or from the tools, not from node --run. A dedicated runner is only warranted when you need real orchestration such as caching or running tasks across workspaces, which is the case for monorepos covered in Workspaces & Publishing.

What changed recently

Table 5. Changes in the tooling story, with versions
Version Change

ESLint 9.0.0

Flat config (eslint.config.js) became the default; .eslintrc stayed available behind ESLINT_USE_FLAT_CONFIG=false.

ESLint 10.0.0

The .eslintrc format is no longer supported. Config lookup starts from each linted file’s directory and the v10_config_lookup_from_file flag was removed. Node.js below 20.19, 21 and 23 are no longer supported. eslint:recommended gained no-unassigned-vars, no-useless-assignment and preserve-caught-error. jiti below 2.2.0 is no longer supported for TypeScript config files.

Node.js 22.0

node --run and stable --watch (the latter replaces nodemon for the common case).

Node.js 24.10 and 22.21

--env-file and --env-file-if-exists are no longer experimental.

Node.js 24.12 and 25.2

Type stripping is Stable; --experimental-transform-types was removed in Node.js 26.0.0, so a lint or build script that passes it fails on 26.

Node.js 26.9

node --run without a name lists the scripts and exits non-zero.

npm 11.19

Install scripts of dependencies are governed by allowScripts and npm install-scripts (observed warning on 11.19.1, described by the npm documentation as blocked by default).

Where the books are outdated

Two O’Reilly books touch this page. Efficient Node.js by Samer Buna (2025) introduces ESLint through the interactive npm init @eslint/config generator and lists Webpack as an example of a bundler tool you can use without a web server; the generator still exists in the ESLint documentation and now produces a flat configuration, and for a Node server the point of this page stands that Webpack is aimed at browser assets, while esbuild and Rolldown are common when a Node bundle is needed. Node.js Projects by Jonathan Wexler (2025) builds the lint and format workflow around nodemon for restarts and refers to editor extensions that activate on .eslintrc and .prettierrc files. On the versions above the .eslintrc family is gone in ESLint 10 (editors need the flat config), and node --watch has been Stable since Node 22.0, so a dependency just for restarts is optional. Neither book describes node --run, the engines-aware eslint-plugin-n, or allowScripts, which came later.

References