npm Scripts
Every Node.js project has a package.json file, and inside it a "scripts" field lets you define short, memorable names for the commands you run all the time — starting the server, running tests, building assets, linting code. Instead of typing a long shell command (and remembering its exact flags) every time, you type npm run build or npm test. npm scripts are the glue that turns a pile of tools into a predictable, repeatable workflow that works the same way for every developer on the team and in CI.
Overview: How npm Scripts Work
The "scripts" field in package.json is just an object mapping a script name to a shell command, as a string. When you run npm run <name>, npm looks up that string and executes it in a new child shell (sh on macOS/Linux, cmd.exe on Windows unless configured otherwise). Because it runs through a real shell, you can use shell operators like && (run the next command only if the previous succeeded), ||, and pipes, exactly as you would on the command line.
The key trick that makes scripts so convenient is PATH manipulation: npm temporarily prepends your project’s node_modules/.bin directory to the shell’s PATH before running the command. That means if you have installed a CLI tool locally (say, eslint or a test runner) with npm install --save-dev eslint, you can write "lint": "eslint ." in your scripts and it will find the locally installed binary — you never need a global install, and you never need to type the full path to node_modules/.bin/eslint.
npm also treats four script names as special/default lifecycle scripts: start, stop, restart, and test. For these you can drop the word run: npm start and npm test both work without run. Every other script name requires the full npm run <name> form. If you don’t define a start script at all, npm falls back to running node server.js if that file exists.
Beyond the special four, npm supports pre/post hooks for any script: if you define both pretest and test, running npm test automatically runs pretest first, then test, then posttest if it exists. This convention-over-configuration approach lets you build small pipelines (lint → build → test) without a separate task runner.
Finally, npm injects useful environment variables into every script’s process: values from package.json are available as process.env.npm_package_* (for example npm_package_version), and any npm config or CLI flags appear as process.env.npm_config_*. This is a lightweight way to read your own package metadata from inside a script without re-parsing package.json.
Syntax
{
"scripts": {
"<name>": "<shell command>"
}
}
| Part | Meaning |
|---|---|
"scripts" |
Top-level object in package.json holding all named commands |
<name> |
The script name you invoke with npm run <name> |
<shell command> |
Any string the OS shell can execute — a node CLI flag, a local binary, a chain of commands |
pre<name> / post<name> |
Optional hooks npm runs automatically before/after <name> |
To run any script: npm run <name>. For start, stop, restart, and test only, you may omit run.
Examples
Example 1: Basic project scripts
{
"name": "todo-api",
"version": "1.0.0",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js",
"test": "node --test",
"lint": "eslint ."
}
}
npm start
# or, while developing, auto-restart on file changes:
npm run dev
The start script runs the app once with plain node — suitable for production. The dev script adds Node’s built-in --watch flag (stable since Node 18.11, no nodemon required) so the process restarts whenever a watched file changes. Notice that npm start works without run because start is one of the special lifecycle names, while npm run dev and npm run lint need the explicit run.
Example 2: Passing arguments through to a script
npm run test -- --watch
npm run lint -- --fix
Anything after a bare -- is passed straight through to the underlying command instead of being interpreted by npm itself. So npm run test -- --watch actually executes node --test --watch, and npm run lint -- --fix executes eslint . --fix. Forgetting the -- is one of the most common npm scripts mistakes (covered below).
Example 3: Lifecycle hooks and reading npm’s own env vars
{
"name": "todo-api",
"version": "2.3.0",
"scripts": {
"pretest": "npm run lint",
"test": "node --test",
"posttest": "node scripts/report-version.js",
"lint": "eslint ."
}
}
console.log(`Ran tests for ${process.env.npm_package_name}@${process.env.npm_package_version}`);
$ npm test
> todo-api@2.3.0 pretest
> npm run lint
> todo-api@2.3.0 lint
> eslint .
> todo-api@2.3.0 test
> node --test
> todo-api@2.3.0 posttest
> node scripts/report-version.js
Ran tests for todo-api@2.3.0
Running npm test triggers the whole chain automatically: pretest (which itself runs lint), then test, then posttest. The report-version.js script reads process.env.npm_package_name and process.env.npm_package_version, values npm derived directly from package.json — no file reading or JSON parsing needed inside the script.
Example 4: A realistic build script
const fs = require("node:fs/promises");
const path = require("node:path");
async function build() {
const pkgPath = path.join(__dirname, "..", "package.json");
const pkgRaw = await fs.readFile(pkgPath, "utf8");
const pkg = JSON.parse(pkgRaw);
const info = {
name: pkg.name,
version: pkg.version,
builtAt: new Date().toISOString(),
};
const outDir = path.join(__dirname, "..", "dist");
await fs.mkdir(outDir, { recursive: true });
await fs.writeFile(
path.join(outDir, "build-info.json"),
JSON.stringify(info, null, 2)
);
console.log(`Built ${info.name}@${info.version}`);
}
build().catch((err) => {
console.error("Build failed:", err.message);
process.exit(1);
});
$ npm run build
> todo-api@2.3.0 build
> node scripts/build.js
Built todo-api@2.3.0
Wired up as "build": "node scripts/build.js", this script uses the promise-based node:fs/promises API so a failed read or write rejects the promise instead of throwing synchronously, and the .catch() ensures a build failure exits with a non-zero status code — important so CI systems correctly detect the failure.
How It Works Step by Step
When you type npm run build, npm does the following, in order:
- Reads
package.jsonin the current directory and looks upscripts.prebuild(if present) andscripts.build. - Builds a temporary
PATHthat has./node_modules/.binprepended, so local CLI tools resolve first. - Spawns a shell process with that modified
PATHand runsprebuildto completion, if it exists. If it exits with a non-zero code, npm stops here andbuildnever runs. - Runs the
buildcommand string itself in the same kind of shell, streaming its stdout/stderr straight to your terminal. - If
buildsucceeds (exit code 0), runspostbuildif it exists. - npm exits with the exit code of the last command that ran, so a failing script anywhere in the chain fails the whole
npm runinvocation — which is exactly what CI pipelines rely on.
Common Mistakes
Mistake 1: Forgetting the -- separator for flags
# Wrong: --watch is swallowed by npm, not passed to the test runner
npm run test --watch
# Correct: everything after -- goes to the underlying command
npm run test -- --watch
Without the separator, npm may interpret --watch as an npm CLI flag rather than an argument for your script, so the underlying command never sees it. Always put a literal -- before extra flags you want forwarded.
Mistake 2: Writing shell-specific commands that break on Windows
{
"scripts": {
"clean": "rm -rf dist"
}
}
rm -rf is a POSIX shell builtin; it does not exist on Windows’ cmd.exe, so this script fails for any teammate or CI runner on Windows. Use a small cross-platform Node script instead, or a package built for this:
npm install --save-dev rimraf
{
"scripts": {
"clean": "rimraf dist"
}
}
Mistake 3: Misnaming pre/post hooks
{
"scripts": {
"test:pre": "eslint .",
"test": "node --test"
}
}
npm only auto-runs a hook if it is named with the exact pre/post prefix directly attached to the script name — test:pre is just an unrelated script that npm will never call automatically. The fix is "pretest", not "test:pre".
Mistake 4: Assuming npm start restarts on file changes
A plain "start": "node src/index.js" runs once and exits when the process exits or crashes — it will not pick up file edits. For local development, add a separate dev script using node --watch (Example 1) rather than repeatedly stopping and restarting npm start by hand.
Best Practices
- Keep script bodies short; if the logic is more than one line, move it into a file under a
scripts/directory and call that file ("build": "node scripts/build.js") so it’s testable and easy to read. - Use
node --watchfor a dev-reload script instead of addingnodemonas a dependency — it’s built in and one less package to maintain. - Prefer cross-platform helper packages (
rimraf,cross-env) over shell builtins likerm,export, orsetthat behave differently on Windows. - Use
pre<name>/post<name>hooks to compose small pipelines (lint before test, notify after build) instead of chaining everything with&&in one long line. - In CI, install dependencies with
npm ci(notnpm install) for reproducible, lockfile-exact installs before running scripts. - Read configuration with
process.env.PORT ?? 3000inside your app rather than hardcoding values into the script string. - Document non-obvious scripts with an inline comment is not possible in JSON, so give scripts clear, descriptive names instead (
build:prodvsbuild:dev) rather than relying on tribal knowledge.
Practice Exercises
- Add a
"dev"script to apackage.jsonthat runssrc/index.jswith Node’s--watchflag, and a"start"script that runs it without watching. Verify withnpm run devthat editing the file restarts the process. - Add a
pretestscript that runseslint .and confirm that runningnpm testexecutes lint before your actual tests, and that a lint failure prevents the tests from running at all. - Write a small
scripts/print-info.jsfile that logsprocess.env.npm_package_nameandprocess.env.npm_package_version, wire it up as an"info"script, and runnpm run infoto confirm the values match yourpackage.json.
Summary
- The
"scripts"field inpackage.jsonmaps short names to shell commands, run withnpm run <name>. start,stop,restart, andtestare special lifecycle names that can be run without the wordrun.- npm automatically runs
pre<name>before andpost<name>after a script of the same base name, letting you build simple pipelines. - npm prepends
node_modules/.bintoPATH, so locally installed CLI tools work in scripts withoutnpxor a global install. - Use a literal
--to forward extra flags to the underlying command. process.env.npm_package_*exposes your ownpackage.jsonfields inside a running script.- Avoid shell-specific builtins (
rm,export) in scripts meant to run on multiple operating systems; prefer small Node scripts or cross-platform packages.
