npm Scripts You're Probably Not Using (But Should Be)
pre/post hooks, cross-env, npm-run-all, argument passing, and built-in variables — the npm script patterns developers Google one at a time, in one place.
On this page
Every JavaScript project has a package.json with a handful of scripts. Most stop at dev, build, and start. But npm scripts have a surprising amount of built-in power — lifecycle hooks, argument forwarding, cross-platform environment variables, parallel execution, and built-in package variables — that eliminates the need for separate tooling in many cases.
These eight patterns appear constantly in mature JavaScript projects but almost never make it into beginner tutorials. None of them require much setup — once you've seen them, you'll reach for them immediately.
- 1
The pre and post Lifecycle Hooks
Prefix any script name with
preorpostand npm runs it automatically before or after the main script — no configuration required. If theprescript exits with a non-zero code, the main script never runs.json{ "scripts": { "prebuild": "npm run typecheck", "build": "next build", "postbuild": "node scripts/generate-sitemap.js" } }prebuild— runs automatically beforenpm run build; a failing typecheck stops the build entirelypostbuild— runs automatically after a successful build; sitemap generation, asset upload, or any cleanup steppreinstall— useful for checking the Node.js or npm version before dependencies installprepare— runs afternpm installand beforenpm publish; this is where Husky registers git hooks
- 2
Passing Arguments with --
Everything after
--in annpm runcommand is forwarded to the underlying script as arguments. This lets you define a base command inpackage.jsonand extend it at the command line without creating a separate script entry for every variation.bash# npm run <script> -- <args forwarded to the command> npm run test -- --watch npm run lint -- --fix npm run build -- --profile # Equivalent to running directly: # jest --watch # eslint src/ --fix # next build --profile- Only one
--separator is needed regardless of how many arguments follow it - Arguments are appended to the end of the script command — they cannot be inserted in the middle
- Works with any CLI:
jest,eslint,tsc,next, and others
- Only one
- 3
Referencing Other Scripts Inside Scripts
Scripts can call other scripts using
npm run <name>. This lets you build reusable primitives and compose them into higher-level commands. The&&operator runs the second command only if the first exits successfully — use it to enforce quality gates.json{ "scripts": { "typecheck": "tsc --noEmit", "lint": "eslint src/", "format": "prettier --write src/", "check": "npm run typecheck && npm run lint", "fix": "npm run format && npm run lint -- --fix" } }npm run checkruns both type check and lint —lintonly runs iftypecheckpassesnpm run fixformats then fixes lint errors — composing two primitives into one convenient command- Prefer
npm run nameover duplicating the underlying command — if the tool changes, you update one place
- 4
Cross-Platform Environment Variables with cross-env
Setting environment variables inline with
KEY=value commandis POSIX shell syntax — it works on macOS and Linux but breaks on Windows Command Prompt and PowerShell.cross-envsolves this with zero configuration.json{ "scripts": { "dev": "cross-env NODE_ENV=development next dev", "build:staging": "cross-env NODE_ENV=staging ANALYZE=true next build", "build:prod": "cross-env NODE_ENV=production next build" } }- Install once:
npm install --save-dev cross-env - Identical syntax on macOS, Linux, and Windows — the package handles the platform translation
- Supports multiple variables in one call:
cross-env NODE_ENV=staging PORT=3001 next start
- Install once:
- 5
Parallel and Serial Execution with npm-run-all
npm runs scripts sequentially by default.
npm-run-alladdsrun-p(parallel) andrun-s(serial) commands for when you need more control.run-sstops the chain immediately if any script fails — ideal for build pipelines.json{ "scripts": { "dev": "run-p dev:app dev:worker", "dev:app": "next dev", "dev:worker": "node --watch src/worker.js", "build": "run-s build:check build:app", "build:check": "npm run typecheck && npm run lint", "build:app": "next build" } }How
run-pandrun-scompare to the alternatives:Sequential Parallel npm default Yes (scripts run one at a time) No && Yes (stops on failure) No & (ampersand) No Yes — broken on Windows run-s Yes (stops on failure) No run-p No Yes — cross-platform - Install:
npm install --save-dev npm-run-all run-pstarts all matched scripts simultaneously — wall-clock time equals the slowest onerun-sruns scripts one at a time and stops on the first failure- Glob patterns work:
run-p watch:*runs everywatch:script without listing each one
- Install:
- 6
Node's Built-in --watch Flag
Since Node.js 18, you can run any file with
--watchand it automatically restarts when the file or its imported dependencies change. Nonodemonrequired for background scripts.json{ "scripts": { "dev": "run-p dev:app dev:worker", "dev:app": "next dev", "dev:worker": "node --watch src/worker.js", "dev:codegen": "node --watch scripts/generate-types.js", "dev:mailer": "node --watch src/queue/mailer.js" } }- Restarts on file changes to the entry file and any
require/importdependencies it loads - Zero configuration and zero additional dependencies — it is part of the Node.js runtime
- Pair with
run-pto run your main app server alongside background workers that auto-restart
- Restarts on file changes to the entry file and any
- 7
The $npm_package_* Built-in Variables
npm exposes every field in your
package.jsonas an environment variable during script execution. The most immediately useful is$npm_package_version.json{ "version": "2.1.4", "scripts": { "build": "next build", "tag": "git tag v$npm_package_version && git push --tags" } }npm run tagcreatesv2.1.4automatically — no hardcoding the version string in two places$npm_package_namegives you the package name,$npm_package_descriptionthe description- Nested fields use double underscores:
$npm_package_repository__urlforrepository.url
- 8
A Script Naming Convention That Scales
As projects grow, a flat list of scripts becomes hard to navigate. Using
:as a namespace separator turns yourscriptssection into self-documenting project structure.json{ "scripts": { "dev": "run-p dev:*", "dev:app": "next dev", "dev:worker": "node --watch src/worker.js", "build": "run-s build:check build:app", "build:check": "npm run typecheck && npm run lint", "build:app": "next build", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:push": "drizzle-kit push", "db:studio": "drizzle-kit studio" } }dev:*,build:*,db:*— namespaced scripts are immediately readable without documentation- Top-level
devandbuildorchestrate the namespaced ones — new developers run the simple command, power users run the specific one run-p dev:*automatically picks up any newdev:*script you add — no need to update the parent- Consistent across projects: a developer who has seen this convention once can navigate any project using it
Frequently Asked Questions
What is the difference between run-p and run-s in npm-run-all?
- `run-p` (parallel) — starts all matched scripts simultaneously. Use for dev mode:
run-p dev:app dev:worker. - `run-s` (serial) — runs scripts one at a time in order, stopping if any script fails. Use for builds:
run-s typecheck lint build. - Glob support: both accept wildcard patterns.
run-p watch:*starts everywatch:script.run-s build:*runs them in order.
Do pre and post hooks run with npm ci?
npm ci triggers the same lifecycle hooks as npm install. preinstall, postinstall, and prepare all run. This is why Husky recommends wrapping the prepare script: "prepare": "husky || true" — so it doesn't fail in environments where .git is absent.
Why does NODE_ENV=development break on Windows?
KEY=value command is POSIX shell syntax. Windows Command Prompt and PowerShell don't support it.
# ❌ Breaks on Windows
NODE_ENV=development next dev
# ✅ Cross-platform with cross-env
cross-env NODE_ENV=development next devInstall once: npm install --save-dev cross-env. Syntax is identical on all platforms after that.
What is the difference between && and & in npm scripts?
| && | & | |
|---|---|---|
| Behavior | Sequential — second runs only if first succeeds | Background — runs second without waiting |
| Cross-platform? | Yes | No — broken on Windows |
| Use for | Quality gates: typecheck && build | Use run-p instead |
When should I use a shell script instead of an npm script?
Use an npm script for running CLI commands, composing scripts, or injecting environment variables. Reach for a Node.js script when you need if/else branches, file loops, or long logic that needs comments:
{
"scripts": {
"deploy": "node scripts/deploy.js"
}
}// Readable, testable, accepts arguments
const env = process.argv[2] || 'staging';
console.log(`Deploying to ${env}...`);
// conditional logic hereThe naming convention pattern is worth pausing on. dev:*, build:*, db:* turns a flat list of scripts into self-documenting project structure — a new team member can read it and understand what the project does without asking.
Most of these patterns replace tools you might reach for separately: Makefiles for task composition, nodemon for file watching, dotenv CLI for environment injection. Check how much of that tooling your package.json scripts can handle before adding another dependency.