Lesson 7 of 20

NPM & Package Management

Installing and Managing Packages

npm is two things wearing one name. It is a public registry holding an enormous quantity of freely published JavaScript packages, and it is the command-line program that talks to that registry. It arrives with Node.js, so you already have it, and it is how essentially every Node project acquires code somebody else wrote.

npm install express does four things, not one. It downloads Express and everything Express itself depends on, writes all of it into node_modules, records the dependency in package.json, and updates package-lock.json with the exact versions it settled on. Keeping those four effects separate in your head removes most npm confusion before it starts, because nearly every puzzling npm situation is one of them not having happened.

The split between dependencies and devDependencies is a real distinction, not bookkeeping. Dependencies are what your program needs while it is running: Express, Mongoose, the database driver. DevDependencies are what you need while building it: a test runner, a linter, a bundler. On a server the usual command installs only the first group, so a runtime library filed by accident under devDependencies works flawlessly on your laptop and then crashes in production with Cannot find module — one of the most demoralising ways to discover a deployment problem.

Local and global installs are also different things. npm install -g puts a command on your machine so you can type it anywhere; it does not make a library importable from your project. Reach for -g only for tools you run in a terminal, and even then npx is usually the better answer, because a globally pinned tool version drifts away from what your project actually expects.

Finally, treat node_modules as disposable. Never commit it, never edit a file inside it, and never be afraid to delete it — everything in there is reconstructable from package.json and the lock file. Students occasionally fix a library bug by editing its source in node_modules, which works until the next install silently reverts it and the bug returns with no explanation.

Example
# Install a package (adds to dependencies)
npm install express
npm i express          # Shorthand

# Install a dev-only package
npm install --save-dev nodemon
npm i -D nodemon       # Shorthand

# Install globally (CLI tools)
npm install -g typescript

# Install everything listed in package.json
npm install

# Install EXACTLY what the lock file says — use this on a server or in CI
npm ci

# Production install: skip devDependencies
npm ci --omit=dev

# Uninstall a package
npm uninstall express

# Update packages
npm update

# Check for outdated packages
npm outdated

# List installed packages
npm list --depth=0
  • dependencies — needed while the program runs: Express, Mongoose, a database driver
  • devDependencies — needed only while you build: linter, test runner, bundler
  • npm i -D <pkg> — the shorthand for adding a development-only dependency
  • npm ci — install exactly what the lock file says; the right command on a server
  • npm ls <pkg> — show which version is installed and what pulled it in
  • npm install -g — a command for your machine, never a library for your project
  • node_modules/ — disposable, never committed, never edited by hand
Notes
  • A globally installed package cannot be imported by your project. npm install -g express followed by require('express') still gives Cannot find module 'express', and the reason is not obvious: Node searches node_modules folders upward from your file and never looks in the global folder at all. Install libraries locally; keep -g for things you type at a terminal.

Version Ranges and Why package-lock.json Is Committed

Open package.json after installing anything and you will find a line like "express": "^4.18.2". That caret is doing something important: it means the entry is a range, not a version. Npm is free to install any release that satisfies it, and "any release that satisfies it" includes ones published after you wrote the line.

The numbers follow a convention called semantic versioning, or semver: major.minor.patch. A patch release fixes bugs, a minor release adds features without breaking anything, and a major release is allowed to break your code. A caret accepts new minor and patch releases; a tilde, ~4.18.2, accepts only new patch releases; a bare 4.18.2 accepts exactly that one. Most projects leave the caret alone, because refusing every bug fix is worse than the risk it carries.

The consequence is the one beginners do not expect. You and a classmate clone the same repository, run npm install a fortnight apart, and end up with genuinely different code — same package.json, different bytes in node_modules, because a package published a new minor version in between. Your project works, theirs does not, and nothing in git explains why.

package-lock.json exists to close that gap. It records the exact resolved version of every package, including packages you never asked for that arrived as dependencies of dependencies. Committing it is what makes an install on your laptop and an install on the deployment server produce identical trees. This is why a lock file in .gitignore is a bug, however machine-generated and unreadable it looks.

Two commands, two different behaviours. npm install may update the lock file to satisfy a changed range; npm ci never does — it installs precisely what the lock file says, and refuses to run at all if the lock file and package.json disagree. Use install when you are deliberately adding or upgrading something, and ci everywhere the build should be reproducible rather than fresh.

Example
// package.json — these are ranges, not versions
{
  "dependencies": {
    "express": "^4.18.2",     // 4.18.2 up to (not including) 5.0.0
    "mongoose": "~8.1.0",     // 8.1.0 up to (not including) 8.2.0
    "left-pad-ish": "1.4.2"   // exactly this version, nothing else
  },
  "devDependencies": {
    "eslint": "^9.0.0"
  }
}

# See what is actually installed right now
npm ls express

# Which of my ranges have newer releases available?
npm outdated

# Upgrade WITHIN the ranges (never crosses a major version)
npm update

# Deliberately cross a major version — read the release notes first
npm install express@5

# On a server or in CI: install the lock file, exactly, and nothing else
npm ci
  • major.minor.patch — a major bump is permitted to break your code
  • ^1.2.3 — accept new minor and patch releases
  • ~1.2.3 — accept new patch releases only
  • 1.2.3 — pinned to exactly one version
  • package-lock.json — the exact tree, including dependencies of dependencies; always commit it
  • npm ci — reproducible install from the lock file; npm install may change it
  • npm update stays inside your ranges, so it will never move you to a new major version
Notes
  • When two branches both add a package, package-lock.json conflicts and the diff is unreadable. Do not hand-edit it. Take either side of the conflict, then run npm install once — npm regenerates a consistent lock file from package.json, which is the only reliable resolution.

npm Scripts and npx

The scripts block turns "how do I run this project?" from tribal knowledge into something written down. Instead of remembering that this project starts with node src/server.js while last month's started with node index.js, everybody types npm start. Deployment platforms lean on the same convention, and so does every teammate who has never opened your repository before.

There is a mechanism underneath that is worth understanding, because it explains something that otherwise looks like magic. When npm runs a script, it temporarily adds node_modules/.bin to your PATH. That is why "test": "jest" works even though jest is not installed globally — npm finds the copy inside your own project. Type jest directly in the terminal and it will very likely say "command not found", which is correct and not a broken install.

That detail also removes a bad habit. You never need to install a build tool globally to use it in a project: install it as a devDependency and call it from a script. The version then travels with the repository, so the tool your teammate runs is the tool you tested against.

npx covers the other case — running something once without adding it to the project at all, such as a scaffolding command. It checks your project first, then downloads the package temporarily if it is not there. That convenience has a sharp edge: npx will happily fetch and execute whatever name you typed, so a mistyped package name is a stranger's code running on your machine. Read what you type before pressing Enter.

One cross-platform trap catches Windows users constantly. A script such as "start": "NODE_ENV=production node index.js" is Unix shell syntax; on Windows it fails with a confusing message about NODE_ENV not being recognised. Scripts run through whatever shell the operating system provides, so keep them simple, or use a helper package such as cross-env when a variable really must be set inline.

Example
// package.json scripts section
{
  "scripts": {
    "start": "node server.js",
    "dev": "nodemon server.js",
    "test": "jest",
    "build": "tsc",
    "lint": "eslint src/",
    "seed": "node scripts/seed.js"
  }
}

// Running scripts
// npm start          — Runs "node server.js"
// npm test           — Runs "jest"
// npm run dev        — Runs "nodemon server.js"
// npm run lint       — Runs "eslint src/"

// npx — Run packages without global install
// npx create-react-app my-app
// npx eslint --init
// npx nodemon server.js
  • npm start and npm test — the only two scripts that skip the word run
  • npm run <name> — every other script you define
  • node_modules/.bin is added to PATH inside a script — that is why local tools "just work"
  • npm run lint -- --fix — everything after -- is passed to the underlying command
  • npx <pkg> — run a package once without adding it to the project
  • Keep script bodies simple; complex shell syntax behaves differently on Windows
Notes
  • If a script works for you and not for a teammate, check whether the tool is installed globally on your machine. Something you added with -g months ago will be found by your shell and missing from theirs, and the repository gives no hint that it was ever needed. Put the tool in devDependencies instead, and the problem cannot recur.

Choosing a Dependency You Can Trust

Every package you install is code that runs with exactly the privileges your own code has. It can read your files, open network connections and read process.env, which is where your database password lives. That is not paranoia; it is simply what a dependency is, and it is why npm install deserves a moment of thought rather than reflex.

The number that matters is not how many packages you install but how many arrive alongside them. One well-known library can pull in dozens of transitive dependencies, each written and maintained by somebody you will never meet. Adding a single line to package.json can genuinely add a hundred authors to your project, and npm ls --all is a sobering thing to run once.

Before installing, ask three quick questions. Is there a built-in that already does this? crypto.randomUUID(), the global fetch, structuredClone and node --test have each replaced a package that used to be installed by nearly everyone. Is the package still maintained — recent releases, issues being answered? And how much does it drag in behind it? A helper you could write in ten lines is rarely worth a dependency.

Two specific risks are worth naming. Typosquatting is a package published under a name one character away from a popular one, waiting for a mistype; check the spelling before you press Enter, particularly with npx, which downloads and executes in one step. And some packages run install scripts automatically as part of installation. That is legitimate for anything that compiles native code, and it is also the most convenient place to hide something unpleasant.

npm audit reports known vulnerabilities in your tree. Read it rather than obeying it. A flaw in a build tool that only ever runs on your laptop is not the same risk as one in the HTTP parsing of your live server, and npm audit fix --force is permitted to install a new major version, which can break your application in exchange for fixing something that never affected it.

Example
# What am I actually depending on?
npm ls --depth=0        # the packages you chose
npm ls --all            # everything, transitive dependencies included

# Known vulnerabilities — read the report, do not obey it blindly
npm audit
npm audit fix           # safe: stays inside your existing version ranges
# npm audit fix --force # can install BREAKING major versions

# Install without running any package's lifecycle scripts
npm install --ignore-scripts

# Things you may not need a package for at all:
node -e "console.log(crypto.randomUUID())"   // built-in ID generation
node -e "fetch('https://example.com').then(r => console.log(r.status))"
node --test                                    // built-in test runner
  • Prefer a built-in — crypto.randomUUID(), fetch, structuredClone, node --test
  • Check the spelling of a package name; typosquatting is a real and cheap attack
  • npm ls --all — see the transitive dependencies nobody consciously chose
  • npm audit — useful and noisy; judge each finding by where that package runs
  • npm audit fix --force can install breaking major versions; read before you run it
  • Fewer, larger, well-maintained dependencies beat a scatter of tiny ones
Notes
  • For a college project the practical rule is simple: install what your framework or a tutorial genuinely requires, and think twice about everything else. Each extra dependency is one more thing that can break your build the week before submission, for reasons that have nothing to do with any code you wrote.

npm Problems and What They Actually Mean

Cannot find module 'x' has three usual causes and they are easy to tell apart. Either the package was never installed in this folder; or you are running from the wrong directory, since Node searches node_modules upward from the file rather than from wherever your terminal happens to be; or — the version that only appears in production — the package sits in devDependencies while the server installed without them.

EACCES during a global install on Linux or macOS means npm is trying to write into a folder your user does not own. The reflex is sudo. Resist it: packages installed as the administrator create files your ordinary user cannot then manage, and every later install compounds the mess. A version manager keeps Node and its global folder inside your home directory, which removes the problem rather than working around it.

ERESOLVE could not resolve is about peer dependencies. A peer dependency is a package saying "I work alongside X, but you must install X yourself" — plugins declare them so that the plugin and its host share one copy rather than two. The error means two packages want incompatible versions of the same peer. The honest fix is to align those versions; --legacy-peer-deps silences the message by ignoring the constraint, which is occasionally right and always worth understanding first.

"It works on my machine" is nearly always a lock file story. Either package-lock.json was never committed, or somebody ran npm install where npm ci was intended, and the two machines resolved different versions of something. Check that the lock file is in git before investigating anything else — it is a thirty-second check that regularly ends the search.

When an install goes badly wrong, the safe reset is to delete node_modules and run npm ci again. Nothing is lost, because everything in that folder is reconstructable. Deleting package-lock.json is a far bigger step: it throws away the exact versions you were working against and asks npm to choose afresh, which can quietly change your dependencies. Keep that as a genuine last resort.

  • Cannot find module — never installed, wrong folder, or filed under devDependencies
  • EACCES on a global install — do not reach for sudo; use a version manager
  • ERESOLVE — a peer dependency conflict; align versions before reaching for a flag
  • EADDRINUSE is not an npm problem — that is your own server still holding the port
  • Works locally, fails on the server — check the lock file is committed and that CI uses npm ci
  • Safe reset: delete node_modules, run npm ci. Last resort only: delete the lock file
Notes
  • Read the first few lines of an npm error, not the last. npm states the actual cause near the top and then prints a long trailer about log file locations, so the sentence you need is usually above the noise rather than below it.
Ask AI