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
| Tool or API | Stability / status (2026-10-04) | Version notes |
|---|---|---|
|
2 Stable (command-line interface) |
|
|
No longer experimental |
Stable since v24.10.0 and v22.21.0 (added in 20.6; |
|
No stability index in the CLI reference |
Added in v22.0.0. |
|
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 |
esbuild |
Third-party, 0.28.2 |
Still a |
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 |
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).
| Job | Built into Node.js | Still an external tool |
|---|---|---|
Restart on change |
|
Not needed in most projects; |
Load a |
|
Not needed; a loader package is only for older runtimes. |
Run the scripts of |
|
|
Run tests |
|
A third-party runner when you want its features. |
Syntax check |
|
The linter, which finds far more than syntax errors. |
Run TypeScript |
Type stripping, see TypeScript in Node.js |
|
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.
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,
]);
Source: ESLint: Configuration Files and ESLint: Language Options.
Four decisions in this file are specific to Node:
-
sourceTypefollows the module system. ESLint documentsmoduleas the default for.jsand.mjs, andcommonjs(top-level function scope, non-strict mode) for code that usesrequire(). A project that mixes both gets one configuration object per extension, as above. -
globals.nodeversusglobals.nodeBuiltin. Measured withglobals17.13.0:globals.nodehas 81 names and includes the CommonJS wrapper variablesrequire,module,exports,dirnameandfilename;globals.nodeBuiltinhas 76 names and leaves them out, while both containprocess,fetch,URLPatternandstructuredClone. In an ES module those five names do not exist at runtime, sonodeBuiltinis the right set: ESLint then reports a leftover__dirnameas the bug it is (see the run below). Useglobals.nodeonly for the.cjsobjects. -
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 whydist/and similar folders belong in the ignore list. -
@eslint/jsrecommendedchanges between ESLint majors: ESLint 10 addedno-unassigned-vars,no-useless-assignmentandpreserve-caught-errorto 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:
$ 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.
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.
/** @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:
$ 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.
Source: Prettier: Integrating with Linters and eslint-config-prettier.
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.
| Bundle when | Reason |
|---|---|
You ship one file or a small image |
A single |
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. |
| Do not bundle when | Reason |
|---|---|
A dependency is a native addon |
A compiled |
Code builds paths from |
The location changes when everything is moved into one file in another folder. |
The dependency uses dynamic |
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).
{
"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:
$ 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:
$ 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.
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
Source: Rolldown: Getting Started.
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.
Source: npm: npm-install-scripts.
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.
{
"scripts": {
"prepare": "husky"
},
"lint-staged": {
"*.{js,mjs}": ["eslint --max-warnings=0", "prettier --check"],
"*.{json,md,yml}": "prettier --check"
}
}
npx lint-staged
Source: Husky: Get started, lint-staged and Prettier: Pre-commit Hook.
A commit of a file with an unused variable and missing layout is refused, and the working tree is put back as it was:
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)
Source: lint-staged, Husky: Get started
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.
$ 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:
{
"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"
}
}
Source: npm Docs: scripts, Node.js API: --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
Source: Node.js documentation: Command-line API (--run) and Node.js Learn: Run Node.js scripts from the command line.
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. Theformat:checkscript 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 --rundoes not runpreandpostscripts, so aprebuildhook is silently skipped; call the steps explicitly, ascheckdoes. -
--env-fileis not applied to the command that--runstarts (the CLI reference says so); put the flag inside the script, asstartdoes. -
node --runruns 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 fromnode --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
| Version | Change |
|---|---|
ESLint 9.0.0 |
Flat config ( |
ESLint 10.0.0 |
The |
Node.js 22.0 |
|
Node.js 24.10 and 22.21 |
|
Node.js 24.12 and 25.2 |
Type stripping is Stable; |
Node.js 26.9 |
|
npm 11.19 |
Install scripts of dependencies are governed by |
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
-
Node.js API: Command-line API, https://nodejs.org/docs/latest/api/cli.html
-
Node.js Learn: Run Node.js scripts from the command line, https://nodejs.org/learn/command-line/run-nodejs-scripts-from-the-command-line
-
Node.js API: Modules: node:module, https://nodejs.org/docs/latest/api/module.html
-
Node.js API: Modules: TypeScript, https://nodejs.org/docs/latest/api/typescript.html
-
Node.js API: Test runner, https://nodejs.org/docs/latest/api/test.html
-
ESLint: Configuration Files, https://eslint.org/docs/latest/use/configure/configuration-files
-
ESLint: Language Options, https://eslint.org/docs/latest/use/configure/language-options
-
ESLint: Ignore Files, https://eslint.org/docs/latest/use/configure/ignore
-
ESLint: Command Line Interface Reference, https://eslint.org/docs/latest/use/command-line-interface
-
ESLint: Migrate to v10.x, https://eslint.org/docs/latest/use/migrate-to-10.0.0
-
ESLint: Migrate to v9.x, https://eslint.org/docs/latest/use/migrate-to-9.0.0
-
ESLint: Getting Started, https://eslint.org/docs/latest/use/getting-started
-
globals package, https://github.com/sindresorhus/globals
-
eslint-plugin-n, https://github.com/eslint-community/eslint-plugin-n
-
typescript-eslint, https://typescript-eslint.io/
-
Prettier: Configuration File, https://prettier.io/docs/configuration
-
Prettier: Options, https://prettier.io/docs/options
-
Prettier: Ignoring Code, https://prettier.io/docs/ignore
-
Prettier: CLI, https://prettier.io/docs/cli
-
Prettier: Integrating with Linters, https://prettier.io/docs/integrating-with-linters
-
Prettier: Pre-commit Hook, https://prettier.io/docs/precommit
-
eslint-config-prettier, https://github.com/prettier/eslint-config-prettier
-
esbuild: API, https://esbuild.github.io/api/
-
Rolldown: Getting Started, https://rolldown.rs/guide/getting-started
-
Husky: Get started, https://typicode.github.io/husky/get-started.html
-
lint-staged, https://github.com/lint-staged/lint-staged
-
Git: githooks, https://git-scm.com/docs/githooks
-
Git: git-config, https://git-scm.com/docs/git-config
-
npm: npm-install-scripts, https://docs.npmjs.com/cli/v11/commands/npm-install-scripts
-
npm: scripts, https://docs.npmjs.com/cli/v11/using-npm/scripts
-
O’Reilly: Efficient Node.js, Samer Buna, https://www.oreilly.com/library/view/efficient-node-js/9781098145187
-
O’Reilly: Node.js Projects, Jonathan Wexler, https://www.oreilly.com/library/view/node-js-projects/9781098173135/