What this module covers: npm is the package manager for Node.js — but it is also the command-line tool you use to run scripts, manage dependencies, and maintain your project. Understanding package.json, semantic versioning, the lock file, and the npm CLI is not optional. Every Node.js project uses these. This module also covers the essential packages every engineer installs on day one and the most common mistakes that cost hours to debug.
Every Node.js project has a package.json at its root. It is the project's manifest — its name, version, dependencies, scripts, and configuration. Create one:
"main" — the entry point when someone requires your package. For apps, this is your server entry file.
"type": "module" — makes all .js files use ES Modules. Omit for CommonJS.
"scripts" — shortcuts for commands. npm run dev runs whatever is under "dev".
"dependencies" — packages required to run in production.
"devDependencies" — packages only needed during development (testing, linting, build tools).
"engines" — documents the Node.js version(s) your project is intended to run on. By default npm does not enforce this — it will still install and run on the "wrong" version, just with a warning. It becomes a hard block only if you also set engine-strict=true in .npmrc.
There's a third dependency field you'll run into once you start using plugin-style packages: "peerDependencies". It declares "this package needs a compatible version of X to already be installed by whoever uses me" — instead of bundling its own copy of X. React component libraries do this with react itself; ESLint plugins do it with eslint.
This is the source of a warning almost every beginner hits and is confused by: npm install reports something like npm WARN some-eslint-plugin@1.0.0 requires a peer of eslint@>=8.0.0 but none is installed. It's not an error — npm is telling you the plugin expects you to also have eslint installed at a compatible version, because it assumes you already do (as the thing it's plugging into). Install the peer dependency yourself and the warning goes away.
Every npm package uses semantic versioning (semver): MAJOR.MINOR.PATCH.
Part
When it changes
Example
MAJOR
Breaking change — your code may need updating
4.0.0 → 5.0.0
MINOR
New features, backwards compatible
4.18.0 → 4.19.0
PATCH
Bug fixes, backwards compatible
4.18.2 → 4.18.3
In package.json, version ranges control which updates you accept automatically:
"express": "4.18.2" // exact — only this version "express": "~4.18.2" // patch updates only — 4.18.x "express": "^4.18.2" // minor + patch updates — 4.x.x (most common) "express": "*" // any version — avoid this "express": ">=4.0.0" // 4.0.0 or higher
^ (caret) is the default when you npm install express. It allows 4.19.0, 4.18.3, but not 5.0.0.
Analogy: The caret (^) is a bouncer who lets in anyone from the same major-version family as long as they don't break the dress code — patch and minor releases are safe guests, a major bump is a completely different party.
The practical rule: use ^ for most packages. Pin to an exact version only if the package is known to have breaking changes in minor versions (rare but it happens).
The lock file records the exact version of every package — and every package's dependencies — that was installed. This is critical.
The problem it solves: package.json says "express": "^4.18.2". On Monday you install and get 4.18.2. On Tuesday a colleague installs and gets 4.19.0 (just released). Your environments are different. The lock file ensures everyone gets exactly the same versions.
From production: A team had an unpinned ^ dependency on a date/timezone library, deep in their package.json. The library shipped what its own semver labeled a "non-breaking" minor version — but that release quietly changed a default timezone-handling behavior. The same week that version was published, CI ran a fresh npm install (no lock file committed for that particular service, or the lock file was stale enough to allow the update) and pulled it in. Nobody touched application code. A UPI reconciliation job that depended on that library to compute settlement cut-off times started miscalculating them — by hours — because a "minor" version bump changed something the job's correctness quietly depended on. This is exactly the failure mode package-lock.json exists to prevent: committing it and using npm ci in CI/CD means a library publishing a surprising "non-breaking" change doesn't silently ride into your build the next time someone runs an install.
# Always commit package-lock.json to version control git add package-lock.json
# Install from lock file exactly (use in CI/CD and production) npm ci
# Install and update lock file (use during development when adding packages) npm install
Key rule: npm ci for CI/CD pipelines and Docker builds. npm install when you're actively developing and want to add/update packages.
Never manually edit package-lock.json. It is generated by npm.
npm audit fix works well when the vulnerable package is one you depend on directly — it just bumps the version. It's much less useful when the vulnerability is several layers deep: your dependency depends on another package, which depends on the vulnerable one. You don't control that inner package.json, so there's nothing for npm audit fix to bump.
The "overrides" field in your own package.json forces a specific version of a package everywhere it appears in your dependency tree, no matter how deeply nested:
After adding this and running npm install, every dependency that would have pulled in an older, vulnerable version of vulnerable-transitive-package gets 2.1.4 instead — even though none of your direct dependencies were updated. This is the tool to reach for when npm audit reports a vulnerability and npm audit fix says there's nothing it can safely do about it.
npm audit only catches vulnerabilities that have already been reported and cataloged. It says nothing about a broader category of risk: supply-chain attacks, where the package itself — not a bug in it — is the threat. Two common forms:
Typosquatting: a malicious package published under a name one keystroke away from a popular package (expres instead of express, reactt instead of react), hoping a rushed npm install or a typo in a script catches someone.
Compromised packages: an attacker gains access to a legitimate, trusted package's publishing credentials and slips malicious code into a new version of a package thousands of projects already depend on. npm audit won't flag this until (and unless) it's discovered and reported after the fact.
There's no single command that eliminates this risk, but the habits that reduce it: double-check package names before installing, be wary of packages with very few downloads or a very new publish date for something claiming to be well-established, and keep package-lock.json committed and use npm ci — so a compromised new version of a transitive dependency can't silently slide into a build just because someone happened to run npm install that week.
The "scripts" field in package.json is more powerful than it looks. npm adds node_modules/.bin to the PATH when running scripts, so you can reference package binaries directly without npx:
When you run npm install, every package and its dependencies are downloaded into node_modules/. This folder can be enormous — a typical project has hundreds of megabytes. Two rules:
Never commit node_modules to git. Add it to .gitignore.
Always commit package-lock.json to git. Your colleagues need it to get the same versions.
# .gitignore node_modules/ dist/ .env *.log
If node_modules gets corrupted or out of sync, delete it and reinstall:
Most packages should be installed locally (in your project's node_modules). This ensures every project uses its own version and colleagues get the same thing.
# Local install (correct for project dependencies) npm install express npm install -D nodemon
# Global install (use only for CLI tools you run anywhere) npm install -g typescript npm install -g nodemon # acceptable for personal tools
# See globally installed packages npm list -g --depth=0
The rule: if the package is a dependency of your project, install it locally. If it's a CLI tool you want available everywhere on your machine (like ts-node for quick scripts), install globally. In CI/CD, always use local packages.
Separate from package.json (which describes your project), .npmrc is a plain config file for npm's own behavior. You've already seen one setting it controls — engine-strict=true, which turns the "engines" field from a warning into a hard install failure. A few other common uses:
# .npmrc
# Enforce the "engines" field instead of just warning engine-strict=true
# Use a different registry (common for private/company packages) registry=https://registry.npmjs.org/
# Save exact versions instead of ^-prefixed ranges by default save-exact=true
.npmrc can live in your project root (applies to that project only), in your home directory (applies to every project you run npm in), or be set globally. Project-level .npmrc is the one worth committing to git — it's how a team agrees on shared npm behavior (like engine-strict) without every developer configuring it by hand.
# ❌ Wrong — running npm install from a subdirectory cd src && npm install express
# ✅ Correct — always from the project root (where package.json is) npm install express
Committing .env:
# Always in .gitignore .env .env.local .env.production
Not pinning the Node.js version:
// package.json — documents the Node version your code targets { "engines": { "node": ">=20.0.0" } }
Note that this field is informational by default: npm install on the wrong Node version prints a warning but still proceeds. If you actually want installs blocked on the wrong version, add engine-strict=true to a .npmrc file. Either way, "engines" alone won't stop someone from running your code on an unsupported version — pair it with .nvmrc for that.
Also create a .nvmrc at the project root:
20
Then anyone running nvm use automatically switches to the right version.
Using npm install in CI/CD:
# ❌ npm install can update the lock file npm install
# ✅ npm ci installs exactly what's in the lock file and is faster npm ci