Installing and Managing Packages
Every non-trivial Node.js project depends on code someone else wrote — a web framework, a database driver, a testing library. npm (Node Package Manager) is the tool, bundled with every Node.js install, that downloads that code, records exactly which versions your project needs, and reinstalls the same versions on any machine. Understanding npm well — not just typing npm install and hoping — is what separates a project that builds reliably in CI from one that mysteriously breaks on a teammate’s laptop.
Overview: How npm Works
npm is three things working together: a command-line tool (installed alongside node), a public registry at registry.npmjs.org hosting over two million packages, and a manifest file format, package.json, that describes your project and its dependencies.
When you run npm install <package>, npm does four things: it resolves which version satisfies the range you asked for, downloads the tarball from the registry, extracts it into a local folder named node_modules, and records the exact version it chose in a file called package-lock.json. Every package your project depends on also has its own dependencies, so npm builds a full dependency tree and, where possible, hoists shared sub-dependencies to the top level of node_modules so they aren’t duplicated for every package that needs them.
package.json distinguishes ordinary runtime dependencies (dependencies) from tools only needed while developing — test runners, linters, bundlers — which go in devDependencies. Both are installed by a plain npm install; the difference matters in production, where you’d run npm install --omit=dev to skip devDependencies entirely and keep the deployed image smaller.
Version numbers follow semantic versioning (semver): MAJOR.MINOR.PATCH, e.g. 4.19.2. A patch bump is a bug fix, a minor bump adds backward-compatible features, and a major bump can break existing code. npm doesn’t pin one exact version by default — it writes a range into package.json (like ^4.19.2), and the exact version actually installed is locked separately in package-lock.json. That split — a flexible range in package.json, an exact snapshot in the lockfile — is the core mechanism that keeps installs both upgradeable and reproducible.
Syntax
npm install # install everything listed in package.json
npm install <package> # add a runtime dependency, save to package.json
npm install <package>@<version> # install a specific version
npm install --save-dev <pkg> # add a devDependency
npm install -g <pkg> # install globally (a CLI tool, not a project dependency)
npm uninstall <package> # remove a dependency
npm update # upgrade packages within their allowed ranges
npm ci # clean install strictly from package-lock.json
| Part | Meaning |
|---|---|
package.json |
Manifest: project metadata, scripts, and dependency ranges |
package-lock.json |
Exact resolved versions of the full dependency tree; must be committed to git |
node_modules/ |
Where installed code actually lives; must NOT be committed to git |
dependencies |
Packages required at runtime (e.g. express) |
devDependencies |
Packages only needed for development/testing (e.g. nodemon) |
Examples
Example 1: Initializing a project and installing a package
mkdir my-api && cd my-api
npm init -y
npm install express
Output:
Wrote to /home/user/my-api/package.json
added 65 packages, and audited 66 packages in 2s
15 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities
npm init -y creates a default package.json without asking questions. npm install express then downloads Express and its dependencies into node_modules, adds an entry under dependencies in package.json, and writes package-lock.json. The generated manifest looks like this:
{
"name": "my-api",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"express": "^4.19.2"
}
}
Now the package is usable in code. Here is a complete, runnable server that uses the dependency you just installed:
const express = require("node:module").createRequire(__filename)("express");
The line above is unusual — in normal code you simply write require("express"), since Express is a third-party package, not a core module, so it never takes the node: prefix (that prefix is reserved for Node’s own built-ins like node:fs or node:path). Here is the actual, idiomatic server:
const express = require("express");
const app = express();
const PORT = process.env.PORT ?? 3000;
app.get("/", (req, res) => {
res.json({ message: "Hello from Express!" });
});
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ error: "Internal server error" });
});
app.listen(PORT, () => {
console.log(`Server listening on port ${PORT}`);
});
Output:
Server listening on port 3000
Run it with node server.js. Because express is listed in dependencies, anyone who clones this project and runs npm install gets the exact same package tree, and the require("express") call resolves correctly.
Example 2: Dependencies vs devDependencies
npm install --save-dev nodemon
This installs nodemon (a tool that restarts your server on file changes) as a devDependency — useful while coding, unnecessary in production. Add a script that uses it:
{
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"dependencies": {
"express": "^4.19.2"
},
"devDependencies": {
"nodemon": "^3.1.0"
}
}
npm run dev now runs nodemon; a production deploy running npm install --omit=dev skips it entirely, since server.js never actually requires it.
Example 3: Semantic versioning ranges
{
"dependencies": {
"express": "^4.19.2",
"lodash": "~4.17.21",
"left-pad": "1.3.0",
"chalk": "*"
}
}
^4.19.2 allows any 4.x.x release at or above 4.19.2 (minor and patch upgrades, no major). ~4.17.21 allows only patch upgrades within 4.17.x. 1.3.0 with no prefix pins that exact version. * accepts any version at all and should almost never be used, since it lets a breaking major release install silently. You can check exactly what’s installed:
const { version } = require("express/package.json");
console.log(`Installed express version: ${version}`);
Output:
Installed express version: 4.19.2
How Package Resolution Works Under the Hood
When you run npm install in a project that already has a package-lock.json, npm does not re-resolve versions from scratch — it reads the lockfile, checks that it’s still compatible with package.json, and installs precisely those recorded versions and their exact sub-dependency versions. This is what makes builds reproducible: two machines running npm install against the same lockfile get byte-identical node_modules trees (modulo platform-specific binaries).
npm ci (“clean install”) is the stricter sibling used in CI/CD pipelines: it deletes any existing node_modules, refuses to run if package.json and package-lock.json are out of sync, and installs strictly from the lockfile. It’s faster and safer than npm install for automated builds precisely because it never modifies the lockfile or re-resolves ranges.
Global installs (npm install -g) are different from project installs: they place a package in a shared global folder (found with npm root -g) intended for command-line tools you run directly from the shell (like npm install -g typescript to get the tsc command everywhere). A globally installed package is not added to any project’s node_modules and cannot be require‘d by your application code — a common source of confusion.
Common Mistakes
Mistake 1: Phantom dependencies
Requiring a package that was never explicitly installed, but happens to be present because some other dependency pulled it in transitively:
// package.json only lists "express" as a dependency,
// but lodash happens to be a sub-dependency of something else
const _ = require("lodash");
console.log(_.chunk([1, 2, 3, 4], 2));
This works today because lodash happens to be hoisted into the top-level node_modules by some other package. If that other package ever drops its own dependency on lodash, or npm’s hoisting decisions change, this line breaks with Cannot find module 'lodash' — with no corresponding change to your own code. Fix it by declaring every package you directly require:
npm install lodash
Now lodash is a first-class entry in dependencies, guaranteed to be present regardless of what else changes in the tree.
Mistake 2: Not committing package-lock.json (or committing node_modules)
Adding node_modules to git bloats the repository with generated, platform-specific files and defeats the purpose of a package manager. The opposite mistake — adding package-lock.json to .gitignore — is just as bad: without it, every fresh npm install re-resolves semver ranges against whatever is currently newest in the registry, so a teammate (or your CI server) can silently get a different, possibly incompatible version than what you tested with. The correct .gitignore entry is node_modules/ only; always commit package-lock.json.
Mistake 3: Using npm install instead of npm ci in automated builds
Running plain npm install in a CI pipeline can update the lockfile if ranges have drifted, masking the fact that a dependency changed. npm ci installs exactly what the lockfile says and fails loudly if it’s inconsistent with package.json, which is what you want in an automated, unattended build.
Best Practices
- Always commit
package-lock.json; never commitnode_modules/. - Run
npm install <pkg>for every package you actuallyrequireorimport— never rely on a transitive install. - Use
npm ci, notnpm install, in CI/CD and Docker builds for fast, reproducible, fail-fast installs. - Keep build/test tools in
devDependenciesand deploy production images withnpm install --omit=dev. - Run
npm outdatedandnpm auditperiodically to catch stale or vulnerable dependencies; usenpm audit fixfor automatic patch-level fixes. - Prefer caret ranges (
^1.2.3) for normal dependencies; avoid bare*or unbounded ranges. - Use
npm install -gonly for CLI tools you run from the shell, never as a substitute for a project dependency. - Use
npx <tool>to run a package’s CLI once without installing it globally.
Practice Exercises
- Create a new folder, run
npm init -y, then installexpressas a dependency andnodemonas a devDependency. Inspect the resultingpackage.jsonand confirm each package landed in the correct section. - Run
npm ls --depth=0in that project to list top-level installed packages, then runnpm outdatedto see if any newer versions are available. - Delete your
node_modulesfolder and runnpm ciinstead ofnpm install. Confirm the versions installed exactly matchpackage-lock.json, and explain in your own words whynpm ciis preferred for CI pipelines.
Summary
- npm installs packages from the registry into
node_modules, tracked by ranges inpackage.jsonand pinned exactly inpackage-lock.json. dependenciesare needed at runtime;devDependenciesonly during development.- Semver ranges (
^,~, exact,*) control how much a version can drift on future installs — prefer^and avoid*. - Always commit
package-lock.json, never commitnode_modules. - Use
npm cifor reproducible, fail-fast installs in CI/CD; usenpm install -gonly for shell-level CLI tools, not project dependencies. - Avoid phantom dependencies — explicitly install every package you
requireorimport.
