Node.js npm
npm (Node Package Manager) ships with every install of Node.js and gives you access to the largest software registry in the world. It solves two problems at once: it lets you pull in reusable code — libraries like express or mongodb — instead of writing everything from scratch, and it lets you describe your own project, its dependencies, and its scripts in one file, package.json, so anyone (a teammate, a server, a CI pipeline) can recreate your exact environment. Almost no real Node.js project is written using only the standard library; nearly everything depends on npm packages, so understanding how npm resolves, installs, and locks versions is essential to working with Node.js at all.
Overview: How npm Works
Every install of Node.js — whether downloaded directly or installed via a version manager like nvm — comes bundled with npm. Run node -v and npm -v in a terminal and you will see both version numbers; npm is versioned and released independently of Node itself. At its core, npm is three things working together: a public registry of published packages (registry.npmjs.org, hosting well over two million packages), a command-line client that talks to that registry and to your local filesystem, and a manifest format, package.json, that describes a project’s name, version, dependencies, and behavior.
When you run npm install with a package name, npm does not simply copy one file — it resolves an entire dependency tree. Your package depends on other packages, which depend on still others, and npm has to pick compatible versions for all of them, download the matching tarballs from the registry, and unpack them into a node_modules folder in your project root. It then records the exact versions it resolved — down to the specific patch release and a cryptographic integrity hash — in package-lock.json. That lock file is what makes an install reproducible: without it, installing express today and installing it again in six months could quietly pull a newer, subtly incompatible version.
Node’s own module loader is what makes all of this usable from code. When a script calls require("express") or import express from "express", Node does not search the whole disk — it looks in ./node_modules/express relative to the requesting file, and if not found there, walks up to the parent directory’s node_modules, and its parent, and so on until it reaches the filesystem root (throwing a MODULE_NOT_FOUND error if nothing turns up). This is why installing a package updates a local node_modules folder rather than some shared system path, and why separate projects on the same machine can each depend on different, even conflicting, versions of the same package without interfering with each other.
npm also distinguishes between packages your application needs at runtime (dependencies), packages only needed while developing or testing (devDependencies, such as a test runner or a file-watcher), and packages you expect whoever installs your package to already provide (peerDependencies, common in plugins for frameworks). It also distinguishes local installs — the default, into the current project’s node_modules — from global installs (the -g flag, into a shared system location), which are meant for command-line tools you want available everywhere, not for a project’s own dependencies.
Syntax
The general form of an npm command is:
npm <command> [package-name[@version]] [flags]
The commands you will reach for constantly:
| Command | What it does |
|---|---|
npm init -y |
Creates a package.json with default values, skipping the interactive prompts |
npm install |
Installs every dependency already listed in package.json |
npm install <pkg> |
Adds <pkg> to dependencies and installs it |
npm install -D <pkg> |
Adds <pkg> to devDependencies instead |
npm install -g <pkg> |
Installs a command-line tool globally, outside any one project |
npm uninstall <pkg> |
Removes a package and its entry from package.json |
npm update |
Upgrades packages to the newest version allowed by their range in package.json |
npm ci |
Clean install strictly from package-lock.json — the command to use in CI and deployment |
npm run <script> |
Runs a custom command defined in the scripts field |
npx <pkg> |
Downloads (if needed) and runs a package’s executable once, without a global install |
npm audit |
Scans installed dependencies against a database of known vulnerabilities |
Semantic Versioning Ranges
Versions in package.json follow semver: MAJOR.MINOR.PATCH. The prefix in front of a version tells npm how far it may upgrade automatically on a plain npm install or npm update:
| Range | Meaning | Would allow |
|---|---|---|
^5.1.0 |
Compatible with 5.1.0 — minor and patch upgrades, never a major bump | 5.2.0, 5.9.9 — not 6.0.0 |
~5.1.0 |
Only patch upgrades | 5.1.1, 5.1.9 — not 5.2.0 |
5.1.0 |
Exact version, pinned | 5.1.0 only |
* or latest |
Any version at all — avoid this in real projects | anything, including breaking major releases |
Examples
Example 1: Starting a New Project
Every npm-managed project begins with a package.json. Instead of answering the interactive questions, -y accepts sensible defaults immediately:
npm init -y
Output:
Wrote to /home/you/project/package.json
This creates a minimal manifest:
{
"name": "npm-lesson-demo",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
name and version identify the package (required if you ever publish it), main points at the file that loads when something requires this package, and scripts holds named shell commands — right now just a placeholder test script that fails on purpose until you add real tests.
Example 2: Installing and Using a Real Package
To add a dependency, name it after install:
npm install express
Output (abbreviated):
added 65 packages, and audited 66 packages in 2s
found 0 vulnerabilities
This single command creates a node_modules folder containing Express and everything it depends on, adds an entry like "express": "^5.1.0" under dependencies in package.json, and writes (or updates) package-lock.json with the exact resolved tree. You can now require it from code exactly like a core module, just without the node: prefix (that prefix is reserved for built-ins):
const express = require("express");
const app = express();
const port = process.env.PORT ?? 3000;
app.get("/", (req, res) => {
res.send("Hello from npm-managed Express!");
});
app.listen(port, () => {
console.log(`Server listening on port ${port}`);
});
Run this with node server.js and visiting http://localhost:3000 returns the greeting. Nothing here is core Node.js — express, its routing, and its request/response helpers all came from the registry, which is the entire point of npm: it turns a one-line install into working, tested functionality you didn’t have to write.
Example 3: Custom Scripts and devDependencies
Real projects rarely run node server.js by hand forever. The scripts field in package.json lets you name commands, and some tools — like nodemon, which restarts your server whenever a file changes — are only useful while developing, so they belong in devDependencies:
{
"name": "npm-lesson-demo",
"version": "1.0.0",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"dependencies": {
"express": "^5.1.0"
},
"devDependencies": {
"nodemon": "^3.1.7"
}
}
npm install --save-dev nodemon
npm run dev
npm run dev looks up dev in scripts and executes it as if you had typed nodemon server.js yourself, with the local node_modules/.bin folder automatically added to the shell’s PATH for that one command — you never need to reference node_modules/.bin/nodemon directly. Two script names, start and test, are special: npm lets you run them as npm start and npm test without the word “run”.
Under the Hood: What Happens During npm install
- npm reads
package.jsonto see which packages, and which version ranges, are declared. - If a
package-lock.jsonalready exists and still satisfies those ranges, npm reuses the exact versions recorded there instead of re-resolving from the registry — this is what keeps installs deterministic across machines and over time. - For anything not already locked, npm queries the registry’s metadata, picks the highest version that satisfies the semver range, and repeats the process recursively for every transitive dependency, building a full dependency graph.
- npm downloads each resolved package as a compressed tarball, verifies it against a cryptographic integrity hash, and extracts it into
node_modules— flattened at the top level where versions don’t conflict, nested inside a dependency’s ownnode_moduleswhen they do. - npm writes or updates
package-lock.jsonwith the exact versions and hashes it resolved, so the next install — on any machine — reproduces this exact tree. - Any lifecycle scripts a package defines (for example, a native addon’s
postinstallcompile step) run at this point, after the files are in place.
Only after all of that is your code actually able to run. When it later calls require("express"), Node’s resolver walks the directory tree looking for a matching node_modules/express — a completely separate, synchronous, filesystem-level step that happens every time your process starts, independent of whatever npm did earlier to get the files there in the first place.
Common Mistakes
Mistake 1: Using npm install Instead of npm ci in Automated Pipelines
npm install is allowed to adjust versions within the ranges in package.json — convenient locally, but on a CI server or a production deploy it means the exact code you tested is not necessarily the exact code that ships. npm ci deletes any existing node_modules and installs strictly from package-lock.json, refusing to run at all if the lock file and package.json have drifted apart:
# In a CI or deployment pipeline:
# Non-deterministic - may resolve different versions than what you tested locally
npm install
# Deterministic - installs exactly what package-lock.json records, and fails fast if it's out of sync
npm ci
Use npm install while actively developing (adding or updating packages); use npm ci anywhere reproducibility matters.
Mistake 2: Mismatching the Module System and package.json’s “type” Field
The "type" field in package.json tells Node how to interpret .js files in that project: "commonjs" (the default if the field is omitted) enables require/module.exports, while "type": "module" switches every plain .js file to ES module syntax, where require does not exist:
// package.json has "type": "module", but this file still uses require()
const express = require("express");
// ReferenceError: require is not defined in ES module scope
Once "type": "module" is set, every plain .js file must use import/export instead:
import express from "express";
const app = express();
const port = process.env.PORT ?? 3000;
app.get("/", (req, res) => {
res.send("Hello from an ES module server!");
});
app.listen(port, () => {
console.log(`Server listening on port ${port}`);
});
If you need to mix styles, Node also honors per-file overrides: a .cjs file is always treated as CommonJS and a .mjs file is always treated as an ES module, regardless of the "type" field.
Mistake 3: Committing node_modules to Version Control
A framework like Express pulls in dozens of transitive dependencies; a typical node_modules folder is easily tens of megabytes and thousands of files. None of that needs to be committed — package.json plus package-lock.json fully describe how to regenerate it exactly with npm ci. Add it to .gitignore instead:
echo "node_modules/" >> .gitignore
package-lock.json, on the other hand, should always be committed — it is the one file that guarantees everyone, and your CI server, installs the identical dependency tree.
Mistake 4: Installing Test or Build Tools as Regular Dependencies
Tools you only use while developing — test runners, linters, file-watchers like nodemon — do not belong in dependencies. Putting them there bloats the set of packages installed in production (with npm install --omit=dev) and blurs what your running application actually needs:
# Wrong - pollutes the production dependency tree with a test-only tool
npm install jest
# Right - keeps it out of production installs, available only for development
npm install --save-dev jest
Best Practices
- Always commit
package-lock.json— it is what makes an install reproducible on every machine, not just yours. - Use
npm ci, notnpm install, in CI pipelines and production deploy steps. - Put a package under
devDependenciesif your shipped application never imports it directly. - Prefer caret ranges (
^) for libraries so you get bug fixes automatically, and pin exact versions where a surprise upgrade would be costly. - Run
npm auditperiodically, and in CI, to catch dependencies with known vulnerabilities. - Avoid global installs for anything a project depends on to build or run — use a local
devDependencyplusnpxor an npm script so collaborators and CI get the same tool version. - Declare a minimum Node version with an
"engines"field so mismatched runtimes fail loudly instead of behaving strangely. - Keep
scriptssmall and composable (abuildscript, adevscript, atestscript) rather than one long shell one-liner. - Never publish or commit secrets through npm — a published package is public forever, even if you unpublish it later.
Practice Exercises
- Create a new folder, run
npm init -y, thennpm install express. Write aserver.jsthat responds with your name at the root route, add a"start": "node server.js"script, and run it withnpm start. - In that same project, install
nodemonas a devDependency and add a"dev"script that runs your server through it. Explain, in your own words, whynodemonshould not be a regulardependency. - Delete your
node_modulesfolder and runnpm ciinstead ofnpm install. Compare how each behaves if you manually edit a version number inpackage.jsonwithout updatingpackage-lock.jsonfirst.
Summary
- npm bundles a public package registry, a CLI, and the
package.jsonmanifest format together. npm installresolves a full dependency tree, downloads it intonode_modules, and locks the exact result inpackage-lock.json.- Node’s module resolver looks for
node_modulesstarting in the current directory and walking upward — this is howrequire/importfinds installed packages. dependenciesship with your app;devDependenciesare for local development and CI only.- Semver ranges (
^,~, exact) control how muchnpm updateis allowed to change automatically. - Use
npm ci, notnpm install, wherever a reproducible install matters — CI, staging, production. - Never commit
node_modules; always commitpackage-lock.json.
