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
npm install <package>: what changes on disk and in the manifestdependenciesversusdevDependencies- Why the distinction really matters at deployment time
peerDependenciesandoptionalDependencies- Installing a version, a range, a tag or a git repository
- Global installs: when they are justified (almost never)
- Choosing a dependency with judgment
- Escena Viva's first real dependencies
- How Node resolves an installed package
- Inspecting and maintaining:
ls,outdated,update,uninstall - The tree, hoisting and duplicate versions
npm install <package>: what changes on disk and in the manifest
npm install <package>: what changes on disk and in the manifestLet's start with the canonical example:
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.
dependencies versus devDependencies
dependencies versus devDependenciesnpm 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.
- 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.
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=devreplaces the old--production, which still works but is deprecated. You will see--productionin old tutorials and in inheritedDockerfiles.
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.
peerDependencies and optionalDependencies
peerDependencies and optionalDependenciesThere 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.
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 |
- 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 rangeDistribution 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:
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-fixAlways 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.
- Global installs: when they are justified (almost never)
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.
- 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.
- Escena Viva's first real dependencies
With that judgment in hand, we add two packages. One for production and one for development.
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:
dotenvdoes 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.
.envhas been in the.gitignoresince Module 1. Credentials live there; it is never committed. What does get committed is a.env.examplewith the keys and fictional values, so whoever clones the repo knows what is needed.
- 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.
- Inspecting and maintaining:
ls, outdated, update, uninstall
ls, outdated, update, uninstallnpm 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:
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.
- 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:
Both ranges are compatible with [email protected], so npm installs a single copy, at the root:
That is hoisting: lifting as much as possible to the root so nothing is duplicated. Now change one range:
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_modulesweighs what it weighs. - Beware of
instanceofacross copies. If two versions of the same package define a class, an object created by one is notinstanceofthe other's class. It is a source of bewildering bugs in the real world. - Hoisting creates phantom dependencies. If
utilitysits at the root ofnode_modules, your own code canrequire('utility')without having declared it and it will work... untilpackage-achanges 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-devout of habit. Everything ends up independenciesand the deployment gets fat. Before every install, ask yourself: does this run on the server? Cannot find modulein production only. It is almost always a production dependency classified as adevDependenciesentry. Move it withnpm install <package>(without-D), which relocates it.- Deleting
node_modulesas a first resort. Sometimes it is necessary, but it usually hides the real problem. First trynpm ls <package>to see what is really installed. - Editing
package.jsonby hand and expecting it to install itself. Changing the file installs nothing; you have to runnpm installafterwards so the lock andnode_modulescatch up. - Installing from a git branch.
#mainchanges under your feet. Always pin a tag or a commit. - Forgetting the quotes around
^or>.npm install package@>=2without quotes makes the shell read>as a redirection and creates a file called=2. - Tip:
npm install --dry-runshows what it would do without touching anything. Perfect before a large install. - Tip: look at the tree right after installing.
npm ls --all | wc -ltells 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:
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
- What Is Node.js?
- Installing and Setting Up the Environment
- Your First Node.js Program
- The Node.js REPL
- Modern JavaScript for Node.js
- The Course Project: the Escena Viva Platform
Module 2: Core Concepts
- Node.js Architecture
- The Event Loop
- Callbacks and Asynchronous Programming
- Promises and async/await
- Events and EventEmitter
- CommonJS Modules and require()
- ES Modules and Interoperability
Module 3: File System and I/O
- Reading and Writing Files
- The fs Module in Depth
- Cross-Platform Paths with the path Module
- Working with Streams
- Transform Streams and pipeline
- Buffers and Binary Data
Module 4: HTTP and Web Servers
- Creating a Simple HTTP Server
- Handling Requests and Responses
- Manual Routing
- Serving Static Files
- Receiving Data: Request Bodies and JSON
- Consuming External APIs from Node.js
Module 5: NPM and Package Management
- Introduction to NPM and package.json
- Installing and Using Packages
- Semantic Versioning and package-lock
- npm Scripts and Project Automation
- Creating and Publishing Packages
- Dependency Security and Maintenance
Module 6: The Express.js Framework
- Introduction to Express.js
- Setting Up an Express Application
- Routing in Express
- Middleware
- Essential Third-Party Middleware
- Input Data Validation
- Error Handling
Module 7: Databases and ORMs
- Introduction to Databases
- Using MongoDB with Mongoose
- CRUD Operations
- Relationships, Population and Advanced Queries
- Using SQL Databases with Sequelize
- Migrations, Transactions and Seed Data
Module 8: Authentication and Authorization
- Introduction to Authentication
- User Registration and Password Hashing
- Sessions and Cookies with Passport.js
- Authentication with JWT
- Role-Based Access Control
- API Security Best Practices
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with Mocha and Chai
- Test Doubles with Sinon
- Integration Testing
- Coverage and Test Automation
- Debugging Node.js Applications
Module 10: Advanced Topics
- The Cluster Module
- Worker Threads
- Caching and Job Queues with Redis
- Performance Optimization
- Building RESTful APIs
- GraphQL with Node.js
Module 11: Deployment and DevOps
- Configuration and Environment Variables
- Logging and Monitoring in Production
- Using PM2 for Process Management
- Packaging with Docker
- Deploying to Heroku and Other PaaS
- Continuous Integration and Deployment
