You close Module 4 with a complete web platform —server, router, static files, orders, HTTP client— and without a single external dependency. That was a deliberate choice and it has paid off enormously: you now know what sits underneath. But it also had a price you paid by hand, line by line: compiling route patterns, parsing bodies, maintaining a MIME type table, computing ETags.

That price is not always worth paying. A real project runs into hundreds of problems other people have already solved, better than you would solve them in an afternoon, and battle-tested by millions of installs. The craft lies in knowing when to lean on that work and when not to, and for that you need to understand the machinery: npm.

Until now Escena Viva has lived without a package.json. You run it with node src/... and it works. That changes in this lesson: we give the project a formal identity, a manifest that declares what it is, which version of Node it runs on and —starting with the next lesson— what it depends on.

Contents

  1. What npm is: two things sharing one name
  2. The ecosystem and its alternatives: yarn, pnpm, bun
  3. npm init: the Escena Viva manifest
  4. The fields of package.json, one by one
  5. The complete package.json for Escena Viva
  6. node_modules: what it is and why it never gets committed
  7. npm install with no arguments
  8. npx: running without installing
  9. Where configuration lives: npm config and .npmrc

  1. What npm is: two things sharing one name

When someone says "npm" they may be talking about two different things, and mixing them up causes constant misunderstandings.

The registry The tool
What it is A public package server at https://registry.npmjs.org The npm command-line program
What it does Stores and serves packages published by anyone Downloads, installs, versions, publishes and runs scripts
Can be swapped for Private registries (Verdaccio, Artifactory, GitHub Packages) yarn, pnpm, bun
Installation None, it is a service Comes bundled with Node.js

The second row is the important one: the registry and the tool are independently interchangeable. You can use pnpm against the public registry, or npm against your company's internal registry. They are not married.

Check that you already have the tool, because the Node installer brought it along:

node -v     # v24.5.0
npm -v      # 11.x.y

If you use nvm or fnm, every Node version ships its own npm. When you switch Node versions with nvm use, you switch npm too without noticing. That is normal and it is correct.

Why it is the largest ecosystem in the world

The public registry holds more than three million packages, more than any other language ecosystem. The reasons are structural:

  • JavaScript runs everywhere: browser, server, mobile, desktop, serverless functions. A single registry serves all those worlds.
  • Publishing is trivial: a package.json with three fields and npm publish. There is no review board.
  • JavaScript's standard library was historically small. Where other languages ship date handling or collection utilities out of the box, JavaScript did not, and the registry filled the gap. Hence its reputation for fragmentation and single-function packages. Today Node's core has grown a great deal —you have seen it across four modules— and many classic dependencies are no longer needed.

The flip side of "publishing is trivial" is that anyone can publish anything. That turns choosing dependencies into an engineering decision rather than a formality: we cover it in 05-02 with a checklist, and in 05-06 from the security angle.

  1. The ecosystem and its alternatives: yarn, pnpm, bun

The npm tool is not the only one that knows how to read a package.json and install dependencies.

Tool Lockfile On-disk layout Strength Weakness
npm package-lock.json Flattened tree in node_modules Ships with Node; zero setup; universal Neither the fastest nor the most compact
yarn (modern) yarn.lock Flattened or Plug'n'Play with no node_modules Mature workspaces, PnP PnP breaks tools that assume node_modules
pnpm pnpm-lock.yaml Global store + hard links Massive disk savings; strict about undeclared dependencies Links confuse some older tools
bun bun.lock Flattened, custom installer Extreme speed; it is also a runtime Young ecosystem; compatibility is not total

The pnpm idea deserves a paragraph because it is genuinely different. Instead of copying every package into each project's node_modules, it keeps a single copy of each version in a global store on your machine (~/.local/share/pnpm/store) and creates links inside the project. If you have ten projects using [email protected], there is one express on disk, not ten: on a laptop with twenty repositories the savings are measured in gigabytes. On top of that pnpm is strict: if your code calls require('anything') without having declared it, it fails. With npm it often works by accident, because flattening leaves that transitive dependency visible at the root (05-02).

Why this course uses npm, even knowing there are faster options: it ships with Node (zero setup, zero divergence between machines); it is the common denominator that all the documentation and every continuous integration system understands; and the concepts are the same —package.json, semver, lock, scripts, registry— so moving to pnpm is five minutes with an equivalence table.

A practical rule for living together: one tool per project. Mixing npm install and yarn add in the same repository produces two contradictory lockfiles and hours of pointless debugging.

  1. npm init: the Escena Viva manifest

The package.json is a JSON file at the root of the project that plays four roles at once: identity (what it is called, what version it is, who maintains it), execution contract (which Node version it needs, whether it is CommonJS or ESM, what its entry point is), dependency declaration (05-02 and 05-03) and task interface through the scripts field (05-04).

Creating it takes a single command. From the project root:

cd ~/escena-viva
npm init

npm init opens an interactive questionnaire with default values in parentheses; pressing Enter accepts the proposed one:

package name: (escena-viva)
version: (1.0.0) 0.1.0
description: Ticket sales platform for cultural events
entry point: (index.js) src/server/server.js
test command:
git repository:
keywords: tickets,events,theater
author: Escena Viva Team <[email protected]>
license: (ISC) MIT

Two details: the default name is the folder's name —if yours is called Escena Viva, with capitals and a space, npm init will complain because of the rules in the next section— and we entered 0.1.0, not 1.0.0, because npm's default value means "stable API, I promise compatibility" and that is a bad promise for a project that is just starting. In 05-03 we will make that jump deliberately.

If you want to skip the questionnaire and accept every default:

npm init -y      # same as --yes

It generates a minimal package.json in an instant. It is perfect for a throwaway lab, and you can always edit it afterwards: package.json is an ordinary text file, there is nothing magical about it. In fact, editing it by hand is the norm once the project exists.

  1. The fields of package.json, one by one

name

The package identifier. Its rules are strict because it ends up as part of a URL: 214 characters maximum, lowercase only (EscenaViva is invalid), no spaces —hyphens are used instead: escena-viva—, it cannot start with . or _, and it must be URL-safe: no accents, no ñ, no ·.

There is a variant with a scope, a prefix with an at sign: @escena-viva/format. The scope is a namespace reserved for a person or an organization, and it has three advantages: it avoids collisions (@escena-viva/format is yours even if format is taken), it groups packages belonging to one team, and it is private by default when publishing, which prevents accidental leaks (05-05). You will see them constantly: @babel/core, @types/node, @eslint/js.

version

The current version of the package in semver format: MAJOR.MINOR.PATCH. It is mandatory in order to publish and it is the backbone of lesson 05-03. For now, just remember that 0.1.0 says "this is still moving".

description and keywords

Free text and a list of keywords. They are search metadata: they feed the search engine on npmjs.com. In a private package they serve no functional purpose, but description is still useful as a reminder of what this repository is.

main

The file that gets loaded when someone calls require('escena-viva'). It is the entry point of the library, not necessarily of the executable. If it is omitted, Node looks for index.js at the root.

In Escena Viva we point it at src/server/server.js, which exports createServer without starting anything thanks to the require.main === module guard from Module 4. That is exactly what you want from an entry point: importing must have no side effects.

The modern field that replaces main with far more control is called exports, and we will meet it in 05-05 when we build a publishable package.

type

It decides how Node interprets this project's .js files:

Value .js files are read as require import
"commonjs" (default) CommonJS Yes Dynamic only, or in .mjs
"module" ES Modules No (use .cjs) Yes

In Module 2 we mentioned this line without developing it; now it has a place to live. Escena Viva declares "type": "commonjs" explicitly. It is the default value, so technically it is redundant, but writing it removes all ambiguity for whoever reads the project and for the tooling.

engines

Declares which Node versions the project supports:

"engines": {
  "node": ">=24.5.0 <25"
}

This must be consistent with the .nvmrc you created in Module 1, but they are two files with different audiences: .nvmrc is read by nvm, fnm, Volta and CI, and it changes your active Node version; engines is read by npm and by deployment platforms, and it warns or fails if the version does not fit. By default it only warns; to turn it into a real error you have to enable it in the .npmrc from section 9.

private

"private": true

A safety switch: with private: true, npm publish refuses to run. Nothing more, and nothing less.

Escena Viva is an application, not a library: it must never end up in the public registry. Putting private: true in every application is a cheap habit that has prevented many leaks of proprietary code caused by an npm publish typed in the wrong folder. You only remove it when you genuinely want to publish, which is what we will do in 05-05 with the @escena-viva/format package.

license

The SPDX identifier of the license: MIT, Apache-2.0, GPL-3.0-only, ISC. For closed source, the conventional value is:

"license": "UNLICENSED"

It is not a decorative field: in 05-06 we will see how the licenses of the whole dependency tree are audited, and that analysis leans on precisely this field.

author and repository

"author": "Escena Viva Team <[email protected]>",
"repository": {
  "type": "git",
  "url": "git+https://github.com/escena-viva/escena-viva.git"
}

repository is more useful than it looks: npm repo <package> opens that URL, and audit tools use it to locate a dependency's source code. When you are investigating whether a package can be trusted, the absence of repository is already a signal.

  1. The complete package.json for Escena Viva

This is the manifest we start the module with. It still has no dependencies —they arrive in 05-02— and no useful scripts —they arrive in 05-04— and its version is 0.1.0.

{
  "name": "escena-viva",
  "version": "0.1.0",
  "private": true,
  "description": "Ticket sales platform for cultural events",
  "keywords": ["tickets", "events", "theater", "capacity"],
  "license": "MIT",
  "author": "Escena Viva Team <[email protected]>",
  "type": "commonjs",
  "main": "src/server/server.js",
  "engines": {
    "node": ">=24.5.0 <25"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/escena-viva/escena-viva.git"
  },
  "scripts": {
    "start": "node src/server/server.js"
  }
}

Go over what that file tells a first-time reader without opening anything else: it is a ticket sales platform, at an early version, private (not published), CommonJS, needs Node 24, starts with npm start and its code lives in a known git repository. Eight lines of metadata that save an entire conversation.

One warning: package.json is strict JSON, with no comments, no trailing commas and double quotes throughout. If npm fails with Unexpected token, it is almost always an extra comma before a }. Check that npm understands it:

npm pkg get name version engines
{
  "name": "escena-viva",
  "version": "0.1.0",
  "engines": {
    "node": ">=24.5.0 <25"
  }
}

npm pkg get and npm pkg set read and write fields without opening an editor, preserving the formatting. They are the right way to touch the manifest from a script:

npm pkg set description="Ticket sales for cultural events"

  1. node_modules: what it is and why it never gets committed

node_modules is the folder where npm drops the code of the downloaded dependencies. It does not exist yet in Escena Viva because there are no dependencies, but it is worth understanding before creating it.

When you studied require resolution in Module 2, you saw that Node, faced with require('something') without a ./, walks up the directory tree looking for node_modules/something at every level. npm is simply the one who fills those folders: it writes files where Node already knew to look, with no extra magic. And it is a huge folder: a modest Express project runs to around 40,000 files.

It never goes into version control. The reasons, in order of importance:

Reason Explanation
It is reproducible package.json + package-lock.json are enough to rebuild it identically. Storing the result alongside the source is redundant.
Size and noise Tens of thousands of files turn every git status and every code review into something unmanageable.
Binary, platform-specific content Some packages compile native binaries for your operating system and architecture. What works on your macOS may not start on the server's Linux.
Unresolvable conflicts A merge conflict inside node_modules is not resolved; it is deleted and reinstalled.

That is why the .gitignore you created in Module 1 already started with that line:

node_modules/
.env
reports/*.csv
*.log

The first line is the critical one. The second one will be too from the next lesson onwards, when dotenv comes into play. And watch out for the asymmetry, which is the most common source of confusion in this module:

  • node_modules/ → ignored (it is the result).
  • package.json and package-lock.json → always committed (they are the source).

  1. npm install with no arguments

Run without a package name, npm install (or its alias npm i) means: "put this project in a state where it can run". It is the first thing anyone types after cloning a repository.

Its exact steps, in order: it reads package.json and gathers dependencies and devDependencies; it reads package-lock.json if it exists, to learn the already-resolved tree; it compares against what is installed and works out what is missing or superfluous; it downloads what is missing from the registry, using the local cache when it can; it verifies the integrity of each package against its hash (integrity); it extracts into node_modules, flattening the tree; it links the executables into node_modules/.bin (key for 05-04); it runs the install scripts of packages that have them (postinstall — take note, 05-06); and finally it updates package-lock.json if anything new had to be resolved.

That last step is the essential difference from npm ci, and we will devote a whole table to it in 05-03: npm install may modify the lock; npm ci never touches it.

In Escena Viva, today, the result is anticlimactic and instructive:

npm install
up to date, audited 1 package in 231ms

found 0 vulnerabilities

"1 package" is the project itself. Zero dependencies, zero vulnerabilities: the safest possible state, and the baseline against which we will compare everything we add.

  1. npx: running without installing

There are two ways to use a command-line tool published on npm.

The old way, a global install:

npm install -g cowsay      # installed on your system, outside the project
cowsay "Hello Escena Viva"

The recommended way, npx:

npx cowsay "Hello Escena Viva"

npx looks in this order: if the executable exists in the project's node_modules/.bin, it uses it; if it is in the npx cache, it uses it; and if not, it downloads it into a temporary cache, runs it and does not install it in the project.

Global install (-g) npx
Where it lands On your system, forever In the cache; it does not pollute the project
Version The one you installed months ago The one declared by the project, or the latest
Team consistency Everyone has their own Everyone uses the same one
Ideal use Daily-use tools unrelated to projects Almost everything else

The real problem with -g is not disk space, it is version drift: you scaffold a project with the global version you installed in March and your teammate with the one they installed yesterday, and the results differ without anyone understanding why.

You can pin the version, which is the advisable thing to do in documentation and in CI:

npx create-express-app@latest my-app
npx eslint@9 src

And the point that closes the circle: if the project declares a tool in devDependencies, npx eslint uses that local copy, downloading nothing. That is why in 05-04 we will be able to write "lint": "eslint src" in the scripts without installing ESLint globally.

  1. Where configuration lives: npm config and .npmrc

npm is configured in layers. To see the effective result:

npm config list
; "user" config from /home/user/.npmrc
init-author-name = "Escena Viva Team"

; node bin location = /home/user/.nvm/versions/node/v24.5.0/bin/node
; cwd = /home/user/escena-viva
; npm version = 11.4.2

With npm config list -l you will also see every default value. The layers are applied in this order, from highest to lowest priority:

Layer Location Typical use
Command line --registry=... One specific run
Environment variables NPM_CONFIG_REGISTRY CI and containers
Project .npmrc ./.npmrc Committed to the repo: team policy
User .npmrc ~/.npmrc Not committed: your tokens
Global .npmrc Next to the npm installation Rarely touched
Default values Built in —

Escena Viva's project .npmrc, which does go into the repository:

# Fail the install if the Node version does not satisfy "engines"
engine-strict=true

# When using "npm install <package>", save an exact range instead of "^"
# (we will justify this in 05-03; for now it is just noted)
save-exact=false

# Default registry, spelled out so nobody has to wonder
registry=https://registry.npmjs.org/

engine-strict=true turns the engines warning into an error: if someone tries to install the project with Node 22, npm digs in its heels. Combined with .nvmrc, the Node version stops being a hallway convention and becomes a verified rule.

And the most important warning in this section: the user .npmrc (~/.npmrc) contains your authentication tokens, in lines of the form //registry.npmjs.org/:_authToken=npm_xxxxxxxx. That file is never uploaded anywhere: a token leaked in a public repository lets anyone publish packages in your name (05-05 and 05-06).

Finally, the default registry is https://registry.npmjs.org/. Changing it has two legitimate use cases: an internal company mirror and a private registry for proprietary packages, usually combined with scopes:

# Only @escena-viva/* packages go to the internal registry
@escena-viva:registry=https://internal.registry.test/

Common Mistakes and Tips

  • Running npm init in the wrong folder. Check with pwd first. If you get it wrong, delete the package.json you created: an orphan manifest in your home folder makes npm treat that whole folder as a project.
  • An invalid package.json. No comments, no trailing commas, double quotes. Validate it quickly with node -e "require('./package.json')": if it prints no error, the JSON is correct.
  • Confusing main with "the file that starts the application". main is what you get when you import the package; what starts the application is scripts.start. Their coinciding here is a convenience, not a rule.
  • Committing node_modules because you forgot the .gitignore. If you already committed it: git rm -r --cached node_modules, add the line and commit. The history will still be heavy, but the damage stops growing.
  • Installing tools with -g out of habit. Every -g is a version that exists only on your machine. Use npx, or declare it in devDependencies.
  • Tip: npm init -y followed by npm pkg set. Faster than the questionnaire and automatable in a project template. And use npm pkg get instead of reading the file with grep: it returns valid JSON and does not break when the formatting changes.

Exercises

Exercise 1. Create the Escena Viva manifest. From the project root, run npm init and answer the questionnaire to produce exactly the package.json from section 5: name escena-viva, version 0.1.0, MIT license, entry point src/server/server.js. Then add private, type and engines by hand. Verify with npm pkg get and check that npm start boots the Module 4 server.

Exercise 2. Check that engine-strict works. Create the project .npmrc with engine-strict=true. Temporarily change engines.node to ">=99.0.0" and run npm install. Observe the exact error. Then remove engine-strict and repeat: what changes? Leave the file as it was.

Exercise 3. npx versus -g. Without installing anything globally, run npx [email protected] "Capacity 3000" and note what npx prints the first time and what it prints the second time. Then find where the cache lives with npm config get cache and explain in two sentences why npx gives more reproducible results across a team than a global install.

Solutions

Solution 1. After the questionnaire, the generated file does not include private, type or engines; you add them by hand or with npm pkg:

npm pkg set private=true --json
npm pkg set type="commonjs"
npm pkg set engines.node=">=24.5.0 <25"
npm pkg set scripts.start="node src/server/server.js"

The --json on the first line is essential: without it, private would be stored as the string "true" and not as the boolean true, and npm would not read it as a switch. It is the most frequent mistake with npm pkg set.

After that, npm start must boot the server exactly as node src/server/server.js does, because that is literally what it runs.

Solution 2. With engine-strict=true and an impossible engines.node:

npm error code EBADENGINE
npm error engine Unsupported engine
npm error engine Not compatible with your version of node/npm: [email protected]
npm error notsup Required: {"node":">=99.0.0"}
npm error notsup Actual:   {"npm":"11.4.2","node":"v24.5.0"}

The install stops with a non-zero exit code. Without engine-strict, the same case produces only an npm warn EBADENGINE and the install continues. The difference matters above all in the Module 11 CI: a warning goes unnoticed in a log of thousands of lines; a failure stops the deployment.

Solution 3. The first run downloads the package and announces it:

Need to install the following packages:
[email protected]
Ok to proceed? (y)

The second finds it in the cache and runs instantly, without asking. The cache lives wherever npm config get cache says (~/.npm/_npx for temporary executables).

Reproducibility comes from two things: npx package@version pins the version in the command itself, so the same command produces the same result on any machine; and when the project declares the tool in devDependencies, npx uses the project's local copy, which is pinned in package-lock.json and identical for the whole team. A global install offers neither guarantee: it depends on when each person installed it.

Conclusion

Escena Viva now has a manifest. You have seen that npm is two things: a public registry with more than three million packages and a command-line tool bundled with Node, and that either can be swapped out independently —the course uses npm for its ubiquity, knowing that pnpm saves disk space with its global store and links, and that yarn and bun play the same game by different rules.

The package.json you created fulfills the four roles from section 3: identity (name, version, description, author, repository), execution contract (type: commonjs, main, engines consistent with the .nvmrc from Module 1), and the two gaps we will fill shortly: dependencies and scripts. And it carries private: true, the cheap switch that stops an application from being published by accident.

You also know that node_modules is just the place where npm drops what Node already knew how to look for since Module 2, that it never goes into the repository because it is reproducible from package.json and the lock, and that npm install with no arguments runs nine very specific steps —of which the last one, touching the lock, is precisely the one npm ci does not do. That npx runs tools without installing them and avoids the version drift caused by -g. And that configuration lives in layers, with a project .npmrc that gets committed (engine-strict=true) and a user one that holds your tokens and is never committed.

In the next lesson, Installing and Using Packages, we finally break the zero-dependency streak: we will install the first ones, tell dependencies apart from devDependencies and see why that distinction changes the size and the security of what you deploy, and —more important than any command— we will build a checklist for deciding whether a dependency deserves to enter the project. Because after writing a whole module without a single one, you already know the answer is not always yes.

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