npm & Package Management
Virtually every modern JavaScript project depends on third-party code, and npm (Node Package Manager) is the tool that fetches, installs, and tracks those dependencies. It ships with Node.js, reads a single package.json manifest to describe your project, and resolves a tree of packages from the public npm registry. Understanding how npm installs versions and locks them down is the difference between a build that works on every machine and one that mysteriously breaks in CI.
Initializing a project
Every npm project starts with a package.json file. Generate one with npm init, which prompts you for metadata, or skip the questions with the -y flag to accept defaults.
npm init -y
This creates a minimal manifest. The most important fields are name, version, scripts, and the two dependency maps:
{
"name": "my-app",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node index.js",
"test": "vitest"
},
"dependencies": {
"express": "^4.19.2"
},
"devDependencies": {
"vitest": "^1.6.0"
}
}
Installing packages
Use npm install (or its alias npm i) to add packages. Where a package lands depends on the flag you pass.
| Command | Effect |
|---|---|
npm install express | Adds to dependencies (needed at runtime) |
npm install -D vitest | Adds to devDependencies (tooling only) |
npm install -g serve | Installs globally, available as a CLI everywhere |
npm install | Installs everything listed in package.json |
npm uninstall express | Removes a package and updates the manifest |
The distinction between dependencies and devDependencies matters in production: a npm install --omit=dev skips dev tooling, producing leaner deployments and smaller container images.
Never commit the
node_modules/folder. It is large, platform-specific, and fully reproducible frompackage.jsonplus the lockfile. Add it to.gitignore.
Semantic versioning and ranges
npm packages follow semver — a MAJOR.MINOR.PATCH scheme. A bump in MAJOR signals breaking changes, MINOR adds backward-compatible features, and PATCH ships bug fixes. The prefix in front of a version controls which future versions npm is allowed to install.
| Range | Meaning | Matches |
|---|---|---|
^4.19.2 | Compatible: allow minor/patch updates | 4.x.x, not 5.0.0 |
~4.19.2 | Allow patch updates only | 4.19.x, not 4.20.0 |
4.19.2 | Exact pin | only 4.19.2 |
>=4.0.0 <5.0.0 | Explicit range | any matching version |
* | Any version | latest (avoid) |
The caret (^) is the default npm adds when you install, and it is the right choice for most libraries that respect semver. Run npm outdated to see which packages have newer versions, and npm update to move within the allowed ranges.
The lockfile
package.json records ranges, but a range like ^4.19.2 could resolve to 4.19.2 today and 4.21.0 next week. The package-lock.json file pins the exact version of every package in the resolved tree, including transitive dependencies, along with integrity hashes. This guarantees that every developer and every CI run installs an identical tree.
In automated environments, prefer npm ci over npm install. It installs strictly from the lockfile, errors if package.json and the lockfile disagree, and wipes node_modules first for a clean, deterministic build.
npm ci
Always commit
package-lock.json. Treating it as disposable defeats reproducible installs and is a common source of “works on my machine” bugs.
Scripts
The scripts field maps names to shell commands, runnable with npm run <name>. The start and test scripts have shortcuts (npm start, npm test). Scripts run with node_modules/.bin on the PATH, so locally installed CLIs work without a global install.
{
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint src",
"test": "vitest run"
}
}
You can chain scripts and pass arguments after --:
npm run lint -- --fix
Running binaries with npx
npx executes a package’s binary without installing it permanently. It checks node_modules/.bin first, then downloads to a temporary cache if needed. This is ideal for one-off scaffolding tools.
npx create-vite@latest my-app
npx eslint --init
Output:
Need to install the following packages:
create-vite@latest
Ok to proceed? (y)
Because npx always fetches the requested version, pin it with @latest or a specific version to avoid surprises from a stale cache.
Alternative package managers
npm is not the only option. Both alternatives read the same package.json and registry but differ in speed and disk usage.
| Tool | Strengths |
|---|---|
| npm | Bundled with Node, ubiquitous, no extra install |
| pnpm | Content-addressable store with hard links — fast and disk-efficient; strict by default |
| Yarn | Workspaces, plug’n’play mode, mature monorepo support |
Each uses its own lockfile (package-lock.json, pnpm-lock.yaml, yarn.lock), so a project should commit to exactly one. Switching tools means deleting the others’ lockfiles to avoid drift.
Best Practices
- Commit both
package.jsonand the lockfile; never commitnode_modules/. - Use
npm ciin CI/CD for fast, deterministic, lockfile-driven installs. - Put runtime needs in
dependenciesand tooling indevDependencies. - Keep the default caret ranges for libraries, but pin exact versions for fragile dependencies.
- Audit regularly with
npm auditand patch known vulnerabilities promptly. - Prefer
npxover global installs for occasional CLI tools to avoid version conflicts. - Standardize on a single package manager per repository to keep one source of truth.