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 commit node_modules/.
  • Run npm install <pkg> for every package you actually require or import — never rely on a transitive install.
  • Use npm ci, not npm install, in CI/CD and Docker builds for fast, reproducible, fail-fast installs.
  • Keep build/test tools in devDependencies and deploy production images with npm install --omit=dev.
  • Run npm outdated and npm audit periodically to catch stale or vulnerable dependencies; use npm audit fix for automatic patch-level fixes.
  • Prefer caret ranges (^1.2.3) for normal dependencies; avoid bare * or unbounded ranges.
  • Use npm install -g only 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 install express as a dependency and nodemon as a devDependency. Inspect the resulting package.json and confirm each package landed in the correct section.
  • Run npm ls --depth=0 in that project to list top-level installed packages, then run npm outdated to see if any newer versions are available.
  • Delete your node_modules folder and run npm ci instead of npm install. Confirm the versions installed exactly match package-lock.json, and explain in your own words why npm ci is preferred for CI pipelines.

Summary

  • npm installs packages from the registry into node_modules, tracked by ranges in package.json and pinned exactly in package-lock.json.
  • dependencies are needed at runtime; devDependencies only during development.
  • Semver ranges (^, ~, exact, *) control how much a version can drift on future installs — prefer ^ and avoid *.
  • Always commit package-lock.json, never commit node_modules.
  • Use npm ci for reproducible, fail-fast installs in CI/CD; use npm install -g only for shell-level CLI tools, not project dependencies.
  • Avoid phantom dependencies — explicitly install every package you require or import.