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
The Full Lifecycle Hook Chain
Every script you write can carry pre and post hooks, but npm also defines its own lifecycle events that fire during install, pack, and publish. Knowing which event fires when is the difference between a hook that runs reliably and one that silently never executes.
The chain is flat, not recursive. npm runs prebuild, then build, then postbuild. It never looks for a preprebuild, so you cannot nest hooks inside hooks. If you need three ordered stages, chain them explicitly or use a serial runner.
Hooks do compose across script boundaries, though. If postbuild calls npm run upload, and upload has its own preupload hook, that hook runs too. Each npm run invocation starts a fresh hook cycle, which is how small primitives end up wiring themselves together without a build tool.
| Lifecycle event | When it fires | Typical use |
|---|---|---|
| preinstall | Before dependencies are resolved and installed | Enforce a Node.js or npm version floor |
| install / postinstall | After the package's dependencies are installed | Native module builds, patch application |
| prepare | On a bare npm install, before npm publish, and when installing a git dependency | Register git hooks, build a library from source |
| prepack | Before a tarball is created by npm pack, npm publish, or a git install | Compile TypeScript into the published output |
| postpack | After the tarball is created | Clean up generated build artifacts |
| prepublishOnly | Only on npm publish, before the package is packed | Run tests and lint as a release gate |
| postpublish | After the package reaches the registry | Push git tags, announce the release |
The event that surprises people is prepare. It runs on a bare npm install inside your own project, which makes it the correct home for developer setup rather than anything publish-related. It also runs for consumers who install your package straight from a git URL, which is why library authors use it to build from source.
That is exactly why Husky tells you to put its installer there. If you are wiring up git hooks alongside Prettier and lint-staged, our guide to setting up Husky, Prettier, and lint-staged in Next.js walks through the full configuration.
Why prepublish Is Deprecated
prepublish is the most misleading name in the npm lifecycle, and it is deprecated for good reason: despite the name, it does not run on npm publish. It runs on npm install and npm ci.
That is an accident of history rather than a design choice. Early npm ran prepublish both when publishing and when installing locally, so packages that used it to compile TypeScript recompiled on every contributor's machine, and packages that used it to run tests slowed down every install in the tree.
npm 4 split the behaviour into two clearly named hooks. Those are what you should use today.
{
"scripts": {
"prepublishOnly": "npm run typecheck && npm run test",
"prepack": "tsc -p tsconfig.build.json",
"prepare": "husky"
}
}prepublishOnly: runs only onnpm publish, before the package is packed. Put your release gate here: tests, lint, a clean git tree check.prepare: runs on a barenpm install, before publish, and on git installs. Put developer setup and source builds here.prepack: runs immediately before the tarball is built. Put output compilation here so the published files are always fresh.prepublish: treat it as removed. npm prints a deprecation warning and the behaviour does not match the name.
Argument Forwarding in npm, yarn, and pnpm
The -- separator is an npm convention, not a universal one. If your team mixes package managers, or your README tells contributors one command while CI runs another, the difference bites quickly.
npm needs the separator because it parses unrecognised flags as its own configuration. npm run test --watch does not pass anything to Jest. npm reads --watch as a config value, sets npm_config_watch=true in the script environment, and runs test with no arguments at all. The suite runs once, exits, and nothing warns you that your flag disappeared.
# npm: the separator is required
npm run test -- --watch
# npm without it: the flag is swallowed as npm config
npm run test --watch # jest runs with zero arguments
# yarn and pnpm forward extra arguments directly
yarn test --watch
pnpm test --watch- npm: the
--separator is mandatory. Anything before it belongs to npm, anything after it belongs to your command. - Yarn 1: extra arguments forward automatically.
yarn test --watchworks, andyarn run test -- --watchis accepted for compatibility. - Yarn 2 and later (Berry): arguments forward automatically. The separator is unnecessary and adds nothing.
- pnpm: arguments forward automatically, and
pnpm run test -- --watchalso works, so npm-style instructions are safe to copy.
Arguments always land at the end of the command. If your script is eslint src/ --max-warnings=0, then npm run lint -- --fix expands to eslint src/ --max-warnings=0 --fix. There is no way to inject a value into the middle of the command string.
When a command genuinely needs an argument in the middle, that is your signal to move it into a real script file that parses its own arguments.
Lifecycle Scripts Are a Supply Chain Risk
Everything in this article cuts both ways. The same postinstall hook that lets you build a native module also lets any package anywhere in your dependency tree run arbitrary code the moment you type npm install.
That code runs with your user permissions, with network access, and with your environment variables in scope. That includes NPM_TOKEN, cloud credentials, and anything else your shell or CI runner exports. Self-replicating worms in the npm ecosystem have used precisely this path: the install script reads a publish token, then pushes a malicious version of every package that token can reach.
The number of packages that genuinely need an install script is small. Everything else in your node_modules is inherited risk you never agreed to.
We broke down how these campaigns actually work in npm postinstall attacks. If you want to check a specific project, the Lifecycle Hook Scanner risk-scores every install hook in a package.json without installing anything.
# Install without running any lifecycle scripts
npm ci --ignore-scripts
# Then re-enable them for packages you actually trust
npm rebuild esbuild sharp
# See what install scripts print instead of hiding their output
npm install --foreground-scripts# Block dependency lifecycle scripts for everyone on the project
ignore-scripts=trueThe tradeoff is real. Setting ignore-scripts=true also blocks your own prepare hook, so Husky will not register itself, and packages with genuine native builds fail at runtime instead of at install time. The workable pattern is to block everything by default and re-enable a short, reviewed allow-list with npm rebuild.
npm ci --ignore-scripts: a safe default for CI, where nothing usually needs to compile locallynpm rebuild <package>: re-runs install lifecycle scripts for named packages onlynpm install --foreground-scripts: prints install script output instead of suppressing it, so you can see what rannpm run <script> --ignore-scripts: runs the named script but skips itspreandposthooks
Parallel and Series Without Extra Dependencies
Before you add a runner, know what the shell already gives you. npm hands your script string to sh on macOS and Linux and to cmd.exe on Windows, so shell operators are available with the usual portability caveats.
{
"scripts": {
"gate": "npm run lint && npm run test && npm run build",
"always": "npm run lint; npm run test",
"fallback": "npm run test:fast || npm run test:full",
"parallel-posix": "npm run dev:api & npm run dev:web & wait"
}
}&&runs the next command only if the previous one exited0. This is your quality gate, and it works on Windows too.;runs the next command regardless of the exit code. Use it only when you genuinely do not care whether the first step failed.||runs the next command only if the previous one failed. Useful for a fallback path or for swallowing an expected error.&backgrounds a command, and a trailingwaitkeeps the parent process alive until every background job finishes.
The background-and-wait trick works, but it degrades badly. A failing background job does not reliably fail the whole script, output from both processes interleaves with no way to tell which line came from where, and pressing Ctrl+C often leaves orphaned processes holding your ports.
That is the gap npm-run-all and concurrently fill. You are not paying a dependency for parallelism itself. You are paying for labelled output, correct exit codes, clean shutdown, and Windows support.
{
"scripts": {
"dev": "concurrently -n api,web -c blue,green --kill-others-on-fail \"npm:dev:api\" \"npm:dev:web\"",
"dev:api": "node --watch src/api.js",
"dev:web": "next dev"
}
}- Two commands where the first must pass: plain
&&in the script string, no dependency needed. - Every
dev:script at once:run-p dev:*, which picks up new scripts automatically. - An ordered pipeline that stops at the first failure:
run-s check lint build. - Colour-coded, prefixed output per process:
concurrently -n api,web -c blue,green. - Kill every process when one crashes:
concurrently --kill-others-on-fail.
The Environment npm Builds for Your Scripts
npm does not simply execute your command string. It constructs an environment first, and several variables in it save you from writing glue code.
The most important one is invisible. npm prepends node_modules/.bin to PATH, which is why "lint": "eslint src/" works with no path prefix, and why the identical command fails when you paste it into a bare terminal.
| Variable | What it contains |
|---|---|
| npm_package_name | The name field from package.json |
| npm_package_version | The version field, useful for tagging and build stamps |
| npm_lifecycle_event | The name of the script currently running, such as prebuild |
| npm_lifecycle_script | The raw command string npm is executing |
| npm_config_<key> | Any npm config value, including flags you passed on the command line |
| npm_execpath | Path to the npm CLI that started the script |
| INIT_CWD | The directory you were standing in when you ran the command, not the package root |
npm_lifecycle_event unlocks a pattern worth knowing: point several hooks at the same Node.js file and branch inside it. One file, no duplicated logic, and the branching lives somewhere you can actually read it.
const event = process.env.npm_lifecycle_event;
if (event === "prebuild") {
const [major] = process.versions.node.split(".").map(Number);
if (major < 20) {
console.error("Node.js 20 or newer is required to build this project.");
process.exitCode = 1;
}
} else if (event === "postbuild") {
console.log(
`Built ${process.env.npm_package_name} v${process.env.npm_package_version}`
);
}npm_config_* is the other half of the story. Any flag npm does not recognise becomes a config value, so npm run deploy --target=staging sets npm_config_target=staging inside the script. It is a crude named-argument mechanism, and it reaches places the -- form cannot.
# Sets npm_config_target=staging in the script environment
npm run deploy --target=staging
# Inside scripts/deploy.mjs:
# const target = process.env.npm_config_target ?? "staging";Script configuration and application configuration are separate concerns. For .env file loading order, the NEXT_PUBLIC_ prefix, and build-time versus runtime resolution, see our guide to Next.js environment variables.
Exit Codes and How a Failing Script Breaks a Chain
Every command in an npm script communicates exactly one thing back to npm: an exit code. Zero means success. Anything else means failure, and npm surfaces it as an ELIFECYCLE error along with the code the command returned.
This is the mechanism behind every gate in this article. prebuild blocks build because a failing tsc --noEmit exits non-zero. npm run lint && npm run build stops because ESLint exits 1 when it finds an error. Nothing is magic; it is all exit codes.
$ npm run build
> myapp@1.0.0 prebuild
> tsc --noEmit
src/index.ts(12,3): error TS2322: Type 'string' is not assignable to type 'number'.
npm ERR! code 2
npm ERR! path /Users/dev/myapp
npm ERR! command failed
npm ERR! command sh -c tsc --noEmitTwo patterns come up constantly. The first is deliberately swallowing a failure, which is why Husky's own documentation suggests "prepare": "husky || true". In a CI container with no .git directory, Husky exits non-zero and would otherwise fail every install.
The second is --if-present, which makes npm exit 0 when the script is not defined instead of erroring. That is what lets a single shared CI workflow run npm run test --if-present across repositories where some have tests and some do not.
# Fails loudly if the script does not exist
npm run test
# Exits 0 when the script is not defined
npm run test --if-present
# Deliberately never fails, whatever happens
npm run optional-step || trueYour CI provider reads the same exit code your terminal does. If you are wiring these scripts into a pipeline, our GitHub Actions tutorial covers how a failing step propagates to the job result and how to cache dependencies between runs.
Running Scripts Across a Monorepo
npm has built-in workspace support, and it extends to scripts. Declare your packages in the root package.json and every npm run command gains flags for targeting them.
{
"name": "my-monorepo",
"private": true,
"workspaces": ["packages/*", "apps/*"],
"scripts": {
"build": "npm run build --workspaces --if-present",
"test": "npm run test --workspaces --if-present",
"dev:web": "npm run dev --workspace=apps/web"
}
}--workspaces(short form-ws): run the script in every workspace package.--workspace=<name-or-path>(short form-w): target one package by itsnamefield or its directory path.--if-present: skip workspaces that do not define the script instead of failing the entire command.--include-workspace-root: also run the script in the root package, which is excluded by default.
Flag placement matters less than it looks. npm parses its own known flags first, so npm run build --workspaces passes --workspaces to npm rather than to your build command. Anything npm does not recognise still ends up as an npm_config_* variable.
What npm does not give you is a dependency graph. Workspace scripts run in resolution order, not topological order, and there is no caching between runs. If packages/ui must build before apps/web, npm will not work that out for you. That is the exact line where Turborepo, Nx, or pnpm's filter syntax start earning their configuration files.
Cross-Platform Pitfalls Beyond cross-env
cross-env fixes environment variables. It does not fix everything else that differs between a POSIX shell and cmd.exe, and contributors on Windows will find the rest within an hour.
| POSIX-only script | Portable replacement |
|---|---|
| rm -rf dist | rimraf dist |
| cp -r public dist/public | copyfiles, or a small Node.js script using node:fs |
| NODE_ENV=production next build | cross-env NODE_ENV=production next build |
| cmd1 & cmd2 & wait | run-p cmd1 cmd2 |
| echo $npm_package_version | node -p process.env.npm_package_version |
| 'single quoted argument' | Double quotes, escaped for JSON |
npm also lets you choose the shell outright. Setting script-shell in .npmrc makes every script run under the shell you name. That is a reasonable move for a team that standardises on Git Bash or WSL, and a poor one for an open-source project where you cannot control contributor machines.
# Force a specific shell for every npm script
script-shell=bash- Use forward slashes in paths. Node.js accepts
src/index.json Windows, and backslashes need double escaping inside JSON strings. &&works incmd.exe, so command chaining is safe. A bare&is not, and behaves differently there.- Single quotes are not string delimiters in
cmd.exe. Use double quotes, escaped for JSON. - Add a
windows-latestentry to your CI matrix that runsnpm ciandnpm run build. It catches almost all of this before a contributor does.
When to Move Logic Out of package.json
package.json is a JSON file, and JSON is a hostile place to write a program. No comments, no line breaks inside a string, escaped quotes everywhere, and no way to write a conditional a reviewer can follow at a glance.
There is a point where a clever one-liner costs more than it saves. These are reliable signals that you have passed it:
- The script string no longer fits on one line in your editor
- It contains an
if, afor, or a pipe intogreporsed - It chains more than two
&&operators - It needs a comment to explain what it does, and JSON has nowhere to put one
- It uses shell syntax that only works on one platform
- Two scripts share a copy-pasted chunk of the same command string
The replacement is deliberately boring. A file under scripts/ gets comments, real argument parsing, testable functions, and a stack trace when it fails.
import { parseArgs } from "node:util";
const { values } = parseArgs({
options: {
env: { type: "string", default: "staging" },
dry: { type: "boolean", default: false },
},
});
if (!["staging", "production"].includes(values.env)) {
console.error(`Unknown environment: ${values.env}`);
process.exitCode = 1;
} else if (values.dry) {
console.log(`Would release to ${values.env}`);
} else {
console.log(`Releasing to ${values.env}`);
}{
"scripts": {
"release": "node scripts/release.mjs",
"release:prod": "node scripts/release.mjs --env=production"
}
}parseArgs ships with Node.js 18.3 and later, so flag parsing no longer needs a dependency. Prefer setting process.exitCode = 1 over calling process.exit(1) so buffered output has a chance to flush before the process ends.
Keep the npm script as the entry point either way. The script name stays part of your project's public interface, and the implementation becomes something you can comment, test, and review.
A postbuild hook is also a good place to check what you are about to ship. Restricting or stripping source maps is a common one, since publishing them hands your original source to anyone who opens DevTools. See how source maps leak your source code for what to look for.
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 hereWhat is the difference between prepare, prepublish, and prepublishOnly?
prepublish is deprecated and, despite the name, does not run on npm publish. It runs on npm install and npm ci, which is the opposite of what almost everyone expects. npm 4 replaced it with two hooks that each do one thing.
| Hook | Runs on | Use it for |
|---|---|---|
| prepare | Bare npm install, npm publish, and git installs | Registering Husky hooks, building a library from source |
| prepublishOnly | npm publish only | Release gates: tests, lint, version checks |
| prepublish | npm install and npm ci (deprecated) | Nothing. Migrate it to one of the two above |
If you find prepublish in an existing package.json, decide what it was actually trying to do. A build step belongs in prepack or prepare. A test gate belongs in prepublishOnly.
How do I stop npm from running install scripts from dependencies?
Use --ignore-scripts on the command, or set it once for the whole project in .npmrc. This blocks preinstall, install, and postinstall hooks in every dependency, which is where npm supply chain attacks execute their payload.
# One-off
npm ci --ignore-scripts
# Project-wide: add to .npmrc
# ignore-scripts=true
# Re-enable only for packages you trust
npm rebuild esbuild sharpThe catch is that this also blocks your own prepare hook, so Husky will not register its git hooks and packages with real native builds will fail later rather than at install time. Block by default, then keep a short reviewed allow-list you rebuild explicitly.
How do I run an npm script in every package of a monorepo?
npm workspaces handle this natively. Add --workspaces to run the script everywhere, and pair it with --if-present so packages that do not define the script are skipped instead of failing the command.
# Every workspace that defines a build script
npm run build --workspaces --if-present
# A single workspace, by name or by path
npm run dev --workspace=apps/web
# Include the repository root package too
npm run lint --workspaces --include-workspace-rootnpm runs these in resolution order with no dependency graph and no caching. If one package must build before another, you need a task runner like Turborepo or Nx, or pnpm's filter syntax, on top of the workspace scripts.
What does npm ERR! code ELIFECYCLE actually mean?
It means the command inside your script exited with a non-zero status. ELIFECYCLE is npm reporting that a lifecycle step failed; it is almost never an npm bug. The real error is in the output directly above it.
- Read the lines above the npm error block first. That is the tool's own message: a TypeScript error, a failing test, a missing binary.
npm ERR! code 1usually means the tool found problems.npm ERR! code 127means the command was not found, often a missing dependency or a typo in the script.- If a
prehook failed, the main script never ran at all. Check the script name printed in the npm output to see which stage broke. - Run the underlying command directly to reproduce it:
npx tsc --noEmitinstead ofnpm run typecheck.
The 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.
Related Articles
Husky + Prettier + lint-staged Setup for Next.js
Set up Husky v9, Prettier, and lint-staged in your Next.js project. Step-by-step guide covering pre-commit hooks with the correct 2026 config.
How to Use Environment Variables in Next.js (Without Leaking Them to the Browser)
Learn how to use .env files in Next.js correctly. Understand NEXT_PUBLIC_, avoid common mistakes, and set variables in Vercel and Cloudflare.