Escena Viva already has a manifest, but its dependency block is still empty. Today we break that: we will install the project's first packages and, above all, learn how to decide which ones deserve to get in.

That second part matters more than the first. Typing npm install something takes ten seconds to learn; knowing whether that something will still be maintained in two years, how many dependencies it drags along, whether its license is compatible with your company and whether Node's core already does the same job — that is engineering judgment. And you have just written a whole module —server, router, static files, HTTP client— with no dependencies at all, so you already know from experience that "install a package" is not the only possible answer to a problem.

Contents

  1. npm install <package>: what changes on disk and in the manifest
  2. dependencies versus devDependencies
  3. Why the distinction really matters at deployment time
  4. peerDependencies and optionalDependencies
  5. Installing a version, a range, a tag or a git repository
  6. Global installs: when they are justified (almost never)
  7. Choosing a dependency with judgment
  8. Escena Viva's first real dependencies
  9. How Node resolves an installed package
  10. Inspecting and maintaining: ls, outdated, update, uninstall
  11. The tree, hoisting and duplicate versions

  1. npm install <package>: what changes on disk and in the manifest

Let's start with the canonical example:

npm install dotenv
added 1 package, and audited 2 packages in 612ms

found 0 vulnerabilities

Five concrete things happened behind those two lines:

What changes Detail
package.json A dependencies block appears with "dotenv": "^17.2.1"
package-lock.json It is created (or updated) with the exact resolved version and its hash
node_modules/ The folder is created with dotenv inside it and its own package.json
node_modules/.bin/ The executables the package declares get linked (dotenv has none)
npm cache The downloaded tarball is kept in ~/.npm/_cacache for future installs

Notice the range: you asked for plain dotenv and npm wrote ^17.2.1, not 17.2.1. That caret is the default behavior and it means "this version or any later compatible one". It is the central topic of 05-03; today it is enough to know that npm does not pin exact versions unless you ask it to.

And one check worth doing once in your life: ls node_modules/dotenv reveals CHANGELOG.md LICENSE README.md config.js lib package.json. There is nothing exotic in there: it is an ordinary Node project, with its package.json and its code. Everything you have learned across four modules applies to what you install too.

  1. dependencies versus devDependencies

npm distinguishes two main blocks, and the difference is purely contractual: it does not change how they are installed on your machine, it changes what gets installed in production.

npm install dotenv               # -> dependencies
npm install --save-dev prettier  # -> devDependencies  (alias: -D)
dependencies devDependencies
Key question Does the code need it at run time? Do I only need it to develop?
Installed by npm install Yes Yes
Installed by npm install --omit=dev Yes No
Typical examples express, mongoose, dotenv, zod eslint, prettier, mocha, nodemon
Mental rule It appears in a require inside src/ It only appears in scripts or in test/

The mental rule in the last row settles 95% of the cases: if a file in src/ requires that package, it is a production dependency. If only your scripts or your tests call it, it is a development one.

This is how the split looks in Escena Viva over the course:

Package Block Why
dotenv dependencies src/config/ requires it when the server boots
express (M6) dependencies It is the running server
helmet, cors, morgan (M6) dependencies Middleware that runs in production
mongoose (M7) dependencies Data access at run time
bcrypt, jsonwebtoken (M8) dependencies Authentication at run time
prettier devDependencies It only formats source code
eslint devDependencies It only analyzes source code
mocha, chai, sinon, supertest (M9) devDependencies They only run in tests
nyc / coverage (M9) devDependencies Tests only

There is one deceptive case: a tool that generates code which does run in production. A compiler or a bundler belongs in devDependencies even though its output is deployed, because what travels to the server is the output, not the tool.

  1. Why the distinction really matters at deployment time

Plenty of people classify at random because "it works the same on my machine". And that is true: in development, npm install installs both blocks. The bill arrives on the server.

# On the production server, or in the Module 11 Dockerfile
npm ci --omit=dev

Three very concrete consequences:

Size. A project with Express, Mongoose and a full test suite can carry 90 MB of node_modules in development and 25 MB with --omit=dev. In a Docker image that is rebuilt and pulled on every deployment, that difference is paid on every deployment, every day.

Install time. Fewer packages to download, verify and extract. In the Module 11 CI, where this happens on every commit, it shows.

Attack surface. This is the weighty reason. Every installed package is someone else's code that can run with your process's permissions. Development tools tend to be the heaviest in transitive dependencies —a full linter drags in dozens of packages— and none of them has any business being on a production server. Taking them out reduces risk without losing anything. We will come back to this with numbers in 05-06.

Historical note: --omit=dev replaces the old --production, which still works but is deprecated. You will see --production in old tutorials and in inherited Dockerfiles.

And the corollary: if you misclassify a production dependency as a devDependencies entry, the application works on your laptop and blows up on the server with an Error: Cannot find module. It is one of the most frequent and most bewildering deployment failures, because the code is identical. If you ever hit it, look first at which block the missing module sits in.

  1. peerDependencies and optionalDependencies

There are two more blocks you will rarely write as a consumer, but which you will see constantly in the packages you install.

peerDependencies says: "I need you, the host project, to have this installed; I do not bring it myself". Its use case is plugins. An ESLint plugin declares eslint as a peer: it makes no sense for the plugin to bring its own copy of ESLint, because then there would be two different ESLints and the plugin would not see the configuration of the one that actually runs.

"peerDependencies": {
  "eslint": ">=9.0.0"
}

Since npm 7, peer dependencies are installed automatically when missing, and npm fails if the version you have does not satisfy the range. Before that it only warned, and that is the cause of the famous ERESOLVE unable to resolve dependency tree errors when installing old plugins.

optionalDependencies says: "try to install this; if it fails, carry on without an error". The classic case is native binaries compiled per operating system: a package may offer a fast C++ version and a pure JavaScript fallback if the build does not work. The package's code checks at run time whether the optional module is available.

Block Who installs it If it fails
dependencies npm, always Error, install aborts
devDependencies npm, unless --omit=dev Error, install aborts
peerDependencies npm 7+ when missing Error if the version does not fit
optionalDependencies npm, when it can Ignored, install continues

  1. Installing a version, a range, a tag or a git repository

npm install accepts a specifier after the name:

npm install [email protected]          # exact version
npm install dotenv@"^17.0.0"       # range (quotes: ^ is special in the shell)
npm install dotenv@latest          # latest published stable
npm install express@next           # pre-release tag
npm install lodash@">=4 <5"        # compound range

Distribution tags (dist-tags) are named aliases pointing at a specific version. Every package has at least latest; many also publish next, beta or canary. Look at them with:

npm dist-tag ls express
beta: 5.2.0-beta.1
latest: 5.1.0

You can also install from git, which is useful when you need a fix that has not been published yet, or when the package is internal and is not in any registry:

npm install git+https://github.com/user/package.git#v2.1.0
npm install github:user/package#branch-with-the-fix

Always pin a tag or a commit (#v2.1.0), never a live branch: a branch changes under your feet and ruins the reproducibility the lock is trying to give you. And bear in mind that a package installed from git does not go through the registry, so npm audit knows nothing about it.

  1. Global installs: when they are justified (almost never)

npm install -g <package>
npm list -g --depth=0     # what you have installed globally

The honest list of cases where -g is justified is short: system tools you use daily that belong to no project —a deployment client, pm2 on a server (Module 11)— and little else. For everything else there are two better options: devDependencies + an npm script if the tool belongs to the project (ESLint, Prettier, Mocha), and npx tool@version for one-offs, such as a template generator.

The underlying reason, already flagged in 05-01, is version drift: a global tool is declared nowhere, does not travel with the repository, does not show up in CI and is not installed on anyone else's machine. A project whose README says "first install X globally" is a project that will fail the first time someone clones it.

  1. Choosing a dependency with judgment

Here is the heart of the lesson. Before typing npm install, run the candidate through this list:

Criterion What to look at Warning sign
Does Node already do it? fetch, crypto.randomUUID, node:test, structuredClone, AbortSignal Installing uuid or node-fetch in 2026
Weekly downloads npmjs.com Under a thousand for something general-purpose
Last release Date on npmjs.com or GitHub More than two years untouched
Open issues Do they get answered? Are there old critical bugs? Hundreds open, zero answers
Transitive dependencies npm ls after installing, or packagephobia.com 200 packages to format a date
Installed size bundlephobia.com / packagephobia.com Tens of MB for one utility
License The license field Missing, or GPL in a closed product (05-06)
Maintainers One person or an organization? A single maintainer with no activity
Alternatives Is there a standard option in the ecosystem? Picking the exotic one for no reason
Cost of writing it Is it 20 lines of your own? Installing out of laziness, not complexity

The two most productive rows are the first and the last.

The first, because Node's core has grown enormously and a good share of the classic dependencies are now redundant:

Classic dependency Core replacement
node-fetch, axios (simple use) The global fetch (M4)
uuid crypto.randomUUID()
rimraf fs.promises.rm(path, { recursive: true }) (M3)
mkdirp fs.promises.mkdir(path, { recursive: true }) (M3)
dotenv (partially) node --env-file=.env
nodemon node --watch (05-04)
mocha / jest (simple cases) node:test (M9)
chalk (simple cases) util.styleText

The last, because sometimes the best dependency is the one you do not install. The ecosystem's most famous case is left-pad: eleven lines of code, and when its author pulled it from the registry in 2016 it broke half the internet's builds, Babel's and React's included. We will come back to that story in 05-05, from the publisher's point of view.

And you have a case of your own, far closer to home: the whole of Module 4 was done without a single dependency. Router, MIME types, ETags, body parsing, retries. Not because there were no packages for that —there are, and they are good— but because the goal was to understand the problem. That exercise left you something valuable: when you install Express in Module 6, it will not be an act of faith. You will know exactly what work it is saving you and at what price.

The conclusion is not "install nothing". It is that every dependency is a debt: code you do not control, which has to be updated, audited and, one day, replaced. Install when the saving is clearly bigger than that debt.

  1. Escena Viva's first real dependencies

With that judgment in hand, we add two packages. One for production and one for development.

npm install dotenv
npm install --save-dev prettier

Why dotenv passes the filter. Until now, the port and the currency API key from Module 4 were read from process.env, with the nuisance of having to export them by hand in every terminal. dotenv loads a .env file into process.env. It is tiny, it has zero dependencies, it is maintained and it is the ecosystem's de facto standard. An honest note: Node already ships --env-file=.env, so in a brand-new project you could do without it; we install it because you will run into it in 90% of real projects and because it is a perfect example of a well-chosen dependency. In Module 11 we will compare the two options in depth.

Why prettier is a development one. It formats source code. Nothing it does makes sense on a server. It goes into devDependencies without discussion.

This is how the manifest ends up:

{
  "name": "escena-viva",
  "version": "0.1.0",
  "private": true,
  "type": "commonjs",
  "main": "src/server/server.js",
  "engines": { "node": ">=24.5.0 <25" },
  "scripts": {
    "start": "node src/server/server.js"
  },
  "dependencies": {
    "dotenv": "^17.2.1"
  },
  "devDependencies": {
    "prettier": "^3.6.2"
  }
}

And the usage, in a new module that centralizes the environment configuration. This file must be loaded before any other that reads process.env:

// src/config/environment.js
'use strict';

const path = require('node:path');
const { ROOT } = require('./paths.js');

// Loads .env into process.env. It does not overwrite what already exists:
// real system variables always beat the file.
require('dotenv').config({ path: path.join(ROOT, '.env') });

// We read once and validate here, not scattered around the code.
const environment = {
  port: Number(process.env.PORT ?? 3000),
  nodeEnv: process.env.NODE_ENV ?? 'development',
  currencyApiKey: process.env.CURRENCY_API_KEY ?? null,
};

if (!Number.isInteger(environment.port) || environment.port < 1 || environment.port > 65535) {
  // Diagnostics on stderr, as throughout the course.
  console.error(`Invalid PORT: ${process.env.PORT}`);
  process.exit(1);
}

module.exports = { environment };

Points worth underlining:

  • dotenv does not overwrite variables already present in the environment. That is exactly what you want: in production the system rules, not a file forgotten in the repository.
  • Validation lives in one place. An invalid port must kill the process at startup, not produce an incomprehensible error three minutes later.
  • .env has been in the .gitignore since Module 1. Credentials live there; it is never committed. What does get committed is a .env.example with the keys and fictional values, so whoever clones the repo knows what is needed.

  1. How Node resolves an installed package

You have known the answer since Module 2, but now there is a real example behind it. When src/config/environment.js runs require('dotenv'): it does not start with ./, ../ or /, and it is not a core module (node:path would be), so it is a package. Node looks for escena-viva/src/config/node_modules/dotenv (does not exist), moves up to escena-viva/src/node_modules/dotenv (does not exist) and moves up to escena-viva/node_modules/dotenv, where it finds it. Then it reads dotenv's package.json, follows its main field (or its exports) to the actual file, runs it once and stores it in the module cache: the second require('dotenv') does not run it again.

It is the same upward search you studied, with no new rules. The only thing that has changed is that now there is something to find. And a practical detail falls out of it: your file's path does not matter. src/config/environment.js and src/server/server.js write the same require('dotenv') with no ../ arithmetic, because the search goes upward.

  1. Inspecting and maintaining: ls, outdated, update, uninstall

npm ls shows the installed tree:

npm ls              # first level only
npm ls --all        # the whole tree
npm ls dotenv       # who depends on dotenv (key in 05-06)
[email protected] /home/user/escena-viva
├── [email protected]
└── [email protected]

That npm ls <package> is the tool you will reach for when an audit warns you about a vulnerability in a package you never installed: it tells you who drags it in.

npm outdated compares what is installed with what is published:

npm outdated
Package   Current  Wanted  Latest  Location            Depended by
dotenv     17.2.1  17.2.3  18.0.1  node_modules/dotenv  escena-viva

The three columns say different things and you have to read them properly:

Column Meaning
Current What you have installed right now
Wanted The highest version that satisfies your range in package.json
Latest The latest published one, whether it satisfies your range or not

If Current < Wanted, an npm update fixes it. If Wanted < Latest, there is a major version waiting and upgrading requires changing the range by hand and reading the new version's release notes. The reason for that distinction is the entire content of 05-03.

npm update raises each package to the highest version its range allows. It never jumps to a major version: it is the safe operation.

npm uninstall <package> (alias npm rm) removes it from node_modules, from package.json and from the lock. Without it, a package you stopped using keeps being installed and keeps showing up in audits forever.

  1. The tree, hoisting and duplicate versions

Your dependencies have dependencies. An npm ls --all on a real Express project can run to hundreds of lines. That tree has to be laid out on a flat file system, and that is where npm makes a decision with consequences.

Suppose this case:

escena-viva
├── package-a  -> needs utility@^1.0.0
└── package-b  -> needs utility@^1.2.0

Both ranges are compatible with [email protected], so npm installs a single copy, at the root:

node_modules/
├── package-a/
├── package-b/
└── utility/      <- 1.4.0, shared by both

That is hoisting: lifting as much as possible to the root so nothing is duplicated. Now change one range:

escena-viva
├── package-a  -> needs utility@^1.0.0
└── package-b  -> needs utility@^2.0.0

1.x and 2.x are incompatible by definition in semver. There is no version that satisfies both, so npm nests:

node_modules/
├── package-a/
├── package-b/
│   └── node_modules/
│       └── utility/    <- 2.1.0, for package-b only
└── utility/            <- 1.4.0, for everyone else

And it works with no tricks thanks to the upward search from section 9: when package-b asks for utility, Node finds the nested copy first and stops looking. Two versions of the same package coexist in the same process without ever noticing each other.

Three practical consequences:

  • There may be three versions of the same thing installed. That is normal, not a bug, and it explains why node_modules weighs what it weighs.
  • Beware of instanceof across copies. If two versions of the same package define a class, an object created by one is not instanceof the other's class. It is a source of bewildering bugs in the real world.
  • Hoisting creates phantom dependencies. If utility sits at the root of node_modules, your own code can require('utility') without having declared it and it will work... until package-a changes its tree and it disappears. This is exactly the trap pnpm avoids by being strict.

npm dedupe tries to reorganize the tree so that more copies are shared. We will see it in 05-03, alongside the file that records this whole tree with millimeter precision: package-lock.json.

Common Mistakes and Tips

  • Installing without --save-dev out of habit. Everything ends up in dependencies and the deployment gets fat. Before every install, ask yourself: does this run on the server?
  • Cannot find module in production only. It is almost always a production dependency classified as a devDependencies entry. Move it with npm install <package> (without -D), which relocates it.
  • Deleting node_modules as a first resort. Sometimes it is necessary, but it usually hides the real problem. First try npm ls <package> to see what is really installed.
  • Editing package.json by hand and expecting it to install itself. Changing the file installs nothing; you have to run npm install afterwards so the lock and node_modules catch up.
  • Installing from a git branch. #main changes under your feet. Always pin a tag or a commit.
  • Forgetting the quotes around ^ or >. npm install package@>=2 without quotes makes the shell read > as a redirection and creates a file called =2.
  • Tip: npm install --dry-run shows what it would do without touching anything. Perfect before a large install.
  • Tip: look at the tree right after installing. npm ls --all | wc -l tells you how many lines of dependencies you have just accepted. Sometimes that figure alone changes your mind.

Exercises

Exercise 1. Classify correctly. For each package, decide whether it would go in dependencies or in devDependencies in Escena Viva and justify it in one sentence: express, mocha, dotenv, helmet, prettier, supertest, mongoose, nodemon. Then explain what specific error the user would see if helmet were misclassified and deployed with npm ci --omit=dev.

Exercise 2. Install and verify dotenv. Install dotenv, create a .env with PORT=4000 and CURRENCY_API_KEY=test-key, write src/config/environment.js as in section 8 and check that the Module 4 server starts on port 4000. Then run PORT=5000 npm start and explain which port it starts on and why.

Exercise 3. Measure the cost of a dependency. Without installing anything yet, apply the checklist from section 7 to express. Then, in a throwaway folder outside the project, run npm init -y && npm install express and compare: how many packages were added (npm ls --all | wc -l), how much node_modules takes up (du -sh node_modules) and what npm audit says. Is it still worth it?

Solutions

Solution 1. Production: express (it is the server), dotenv (required at startup), helmet (middleware that runs on every request), mongoose (data access at run time). Development: mocha and supertest (tests only), prettier (only formats source), nodemon (only restarts in development; and besides, we will replace it with node --watch in 05-04).

If helmet were in devDependencies, a deployment with --omit=dev would not install it and the process would die at startup, in the require, not on the first request:

Error: Cannot find module 'helmet'
Require stack:
- /app/src/server/server.js

The bewildering part is that locally everything works perfectly, because there npm install did install both blocks. It is the most compelling argument for classifying correctly from the start.

Solution 2. With the .env at the root and require('./config/environment.js') at the beginning of the startup path, the server listens on 4000.

With PORT=5000 npm start it starts on 5000. The reason is in section 8: dotenv does not overwrite variables that already exist in process.env. The real environment variable is set before the process starts, so when dotenv arrives and sees PORT already defined, it respects it.

That precedence is exactly what you want in production: .env is a convenient default for development, and the container's or the server's real environment always wins. If you ever needed the opposite —rare, and almost always a symptom of another problem— there is override: true.

Solution 3. The approximate numbers today, with Express 5:

added 66 packages, and audited 67 packages in 2s
found 0 vulnerabilities

$ du -sh node_modules
2.1M    node_modules

About 66 packages for one installed. That sounds like a lot, and the initial reaction is usually rejection. But apply the list: tens of millions of weekly downloads, maintained by a foundation (OpenJS) rather than one person, MIT license, 2 MB on disk, fifteen years of history and zero known vulnerabilities. And you know first-hand the work it replaces: it is the whole of Module 4 and quite a bit more.

The contrast is the lesson. Sixty-six packages for Express, with that backing, is a good purchase. Sixty-six packages for a function that formats dates and that you could write in twenty lines is not. The number alone does not decide; it decides in relation to what you get.

Conclusion

Escena Viva now has its first dependencies and, more importantly, a criterion for choosing them. You know what npm install <package> touches —manifest, lock, node_modules, .bin and cache— and that npm writes ranges with ^, not exact versions, something we will take apart in the next lesson.

You can tell dependencies from devDependencies with the "does it appear in a require inside src/?" rule, and you know that distinction is not bureaucracy: in a deployment with npm ci --omit=dev it determines image size, install time and above all the attack surface, because development tools are the ones that drag in the most code and none of them has any business being on a server. You also recognize peerDependencies —the plugin contract, which npm 7 onwards genuinely enforces— and optionalDependencies, which fail silently on purpose.

The checklist in section 7 is what you take away for good: look first at whether Node's core already does it (fetch, randomUUID, rm recursive, --watch, node:test), and then at downloads, maintenance, transitives, size and license. Every dependency is a debt that has to be updated, audited and one day replaced; sometimes the best one is the one you do not install, as the whole of Module 4 demonstrated and as the left-pad story reminds us.

And you finally understand how it all holds together underneath: require('dotenv') works through the same upward search from Module 2, and hoisting shares a single copy when the ranges are compatible and nests when they are not, so two versions of the same package can coexist in the same process —with their instanceof trap and their phantom dependencies.

That phrase, "when the ranges are compatible", is the doorway to the next lesson. In Semantic Versioning and package-lock we will see what ^1.2.3 really promises, why ^0.x.y behaves differently, what your project installs six months from now without you having touched a thing, and how package-lock.json turns all those promises into an exact, reproducible, hash-verified tree.

Node.js Course: From Beginner to Advanced

Module 1: Introduction to Node.js

Module 2: Core Concepts

Module 3: File System and I/O

Module 4: HTTP and Web Servers

Module 5: NPM and Package Management

Module 6: The Express.js Framework

Module 7: Databases and ORMs

Module 8: Authentication and Authorization

Module 9: Testing and Debugging

Module 10: Advanced Topics

Module 11: Deployment and DevOps

Module 12: Real-World Projects

© Copyright 2026. All rights reserved