Skip to content
Node.js nd modules 4 min read

npm Scripts & Lifecycle Hooks

npm scripts turn your package.json into a lightweight task runner. Instead of memorizing long CLI invocations, you define short, named commands that anyone on the team can run with npm run <name>. They are the standard way to wire up building, testing, linting, and starting a Node.js project, and because they ship with npm itself, they require no extra dependencies. Lifecycle hooks then let you chain setup and cleanup steps automatically around those commands.

Defining scripts

Scripts live under the scripts key in package.json. Each entry maps a name to a shell command. npm runs that command through your platform’s shell, with node_modules/.bin prepended to the PATH so locally installed binaries are callable by name.

{
  "name": "my-app",
  "type": "module",
  "scripts": {
    "start": "node server.js",
    "dev": "node --watch server.js",
    "test": "node --test",
    "lint": "eslint .",
    "build": "esbuild src/index.js --bundle --outfile=dist/app.js"
  }
}

Because node_modules/.bin is on the PATH, you write eslint . rather than ./node_modules/.bin/eslint .. This keeps scripts portable across machines and CI.

Running scripts

Use npm run <name> to execute any script. A few names are special and can drop the run keyword: start, test, stop, and restart can be invoked directly.

npm run lint        # explicit
npm start           # shorthand for: npm run start
npm test            # shorthand for: npm run test

Running npm run with no arguments lists every available script — handy for discovering what a project supports.

Output:

Lifecycle scripts included in [email protected]:
  start
    node server.js
available via `npm run-script`:
  dev
    node --watch server.js
  test
    node --test
  lint
    eslint .
  build
    esbuild src/index.js --bundle --outfile=dist/app.js

Pre and post lifecycle hooks

For any script named foo, npm automatically runs prefoo before it and postfoo after it — if those scripts exist. This works for both built-in scripts (like start and test) and your own custom names. Hooks are perfect for setup and teardown that must always accompany a task.

{
  "scripts": {
    "prebuild": "rimraf dist",
    "build": "esbuild src/index.js --bundle --outfile=dist/app.js",
    "postbuild": "node scripts/report-size.js",
    "pretest": "npm run lint",
    "test": "node --test"
  }
}

Running npm run build now executes prebuild, then build, then postbuild in order. If any step exits with a non-zero code, the chain stops immediately.

npm only auto-runs the pre/post prefixes. There is no pre-pre chaining, and arbitrary middle hooks like during do not exist. Keep hook scripts focused on a single responsibility.

Passing arguments with --

To forward extra arguments from the command line into the underlying command, separate them with a --. Everything after it is appended to the script’s command verbatim.

npm test -- --test-name-pattern="auth"
npm run lint -- --fix src/

Here npm test -- --test-name-pattern="auth" runs node --test --test-name-pattern="auth". Without the --, npm would try to interpret the flags as its own.

Running scripts in series and parallel

npm composes scripts by calling itself. To run several scripts in series, chain them with && so each only proceeds if the previous succeeded. For parallel execution, use & to background commands, though a dedicated tool gives cleaner output and proper exit handling.

{
  "scripts": {
    "ci": "npm run lint && npm run test && npm run build",
    "watch:js": "node --watch-path=src src/index.js",
    "watch:css": "tailwindcss -i in.css -o out.css --watch",
    "dev:all": "npm-run-all --parallel watch:js watch:css"
  }
}
Operator / toolBehaviorFails fast?
&&Run in series, stop on first failureYes
;Run in series regardless of failuresNo
&Run in parallel (background), shell-dependentNo
npm-run-all -pCross-platform parallel runnerConfigurable
concurrentlyParallel with labeled, color-coded outputConfigurable

The && and & operators behave differently across shells, and & does not work reliably on Windows. For cross-platform parallel scripts, prefer npm-run-all or concurrently.

Running one-off binaries with npx

npx runs a package binary without installing it as a project dependency. If the package is already in node_modules, npx uses that copy; otherwise it downloads it to a temporary cache, runs it, and discards it. This is ideal for scaffolding tools and infrequent commands.

npx create-vite@latest my-app
npx prettier --check .
npx tsc --noEmit

You can pin a version explicitly to keep runs reproducible:

npx [email protected] src/index.js --bundle

Because npx resolves to local binaries first, running npx eslint inside a project uses your installed, version-locked ESLint rather than a global or fetched one — keeping behavior consistent between your machine and CI.

Best Practices

  • Keep individual scripts small and compose them with npm run so each step stays reusable and testable in isolation.
  • Use pre/post hooks for setup and cleanup that must always run, like cleaning a dist folder before a build.
  • Reach for npm-run-all or concurrently instead of raw & when you need cross-platform parallelism.
  • Always separate forwarded flags with -- so npm does not swallow them.
  • Prefer locally installed binaries (resolved via node_modules/.bin) over globals to pin versions per project.
  • Run npm run with no arguments, or document scripts in your README, so teammates can discover available tasks.
  • Avoid putting secrets directly in script commands; read them from environment variables instead.
Last updated June 14, 2026
Was this helpful?