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:

  1. Reads package.json in the current directory and looks up scripts.prebuild (if present) and scripts.build.
  2. Builds a temporary PATH that has ./node_modules/.bin prepended, so local CLI tools resolve first.
  3. Spawns a shell process with that modified PATH and runs prebuild to completion, if it exists. If it exits with a non-zero code, npm stops here and build never runs.
  4. Runs the build command string itself in the same kind of shell, streaming its stdout/stderr straight to your terminal.
  5. If build succeeds (exit code 0), runs postbuild if it exists.
  6. npm exits with the exit code of the last command that ran, so a failing script anywhere in the chain fails the whole npm run invocation — 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 --watch for a dev-reload script instead of adding nodemon as a dependency — it’s built in and one less package to maintain.
  • Prefer cross-platform helper packages (rimraf, cross-env) over shell builtins like rm, export, or set that 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 (not npm install) for reproducible, lockfile-exact installs before running scripts.
  • Read configuration with process.env.PORT ?? 3000 inside 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:prod vs build:dev) rather than relying on tribal knowledge.

Practice Exercises

  1. Add a "dev" script to a package.json that runs src/index.js with Node’s --watch flag, and a "start" script that runs it without watching. Verify with npm run dev that editing the file restarts the process.
  2. Add a pretest script that runs eslint . and confirm that running npm test executes lint before your actual tests, and that a lint failure prevents the tests from running at all.
  3. Write a small scripts/print-info.js file that logs process.env.npm_package_name and process.env.npm_package_version, wire it up as an "info" script, and run npm run info to confirm the values match your package.json.

Summary

  • The "scripts" field in package.json maps short names to shell commands, run with npm run <name>.
  • start, stop, restart, and test are special lifecycle names that can be run without the word run.
  • npm automatically runs pre<name> before and post<name> after a script of the same base name, letting you build simple pipelines.
  • npm prepends node_modules/.bin to PATH, so locally installed CLI tools work in scripts without npx or a global install.
  • Use a literal -- to forward extra flags to the underlying command.
  • process.env.npm_package_* exposes your own package.json fields 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.