Modules and npm all 4 written
How a program becomes more than one file, the two module systems Node has to live with, and what npm actually does to your folder.
20 ES modules: import and export
One file is fine for a script. Anything bigger is split into
modules: files that share only what they choose to export, and use
only what they choose to import. Node has two module systems; this is the modern one,
the same one browsers use.
Named exports
maths.mjs
export const TAX_RATE = 0.2;
export function add(a, b) {
return a + b;
}
// Not exported: nothing outside this file can see it.
function secret() {
return "hidden";
}
example.mjs
import { add, TAX_RATE } from "./maths.mjs";
console.log(add(2, 3), TAX_RATE);
check("imported a function and a constant", [add(2, 3), TAX_RATE], [5, 0.2]);
What node printed, when the build ran it
node example.mjs
5 0.2 ok imported a function and a constant
Everything in a module is private unless it is exported. secret exists, and can be
used inside maths.mjs, but no other file can reach it. That is the whole point: a
module decides what its public surface is, and can change everything else freely.
Default exports, renaming, and namespaces
money.mjs
export const currency = "GBP";
export default function formatPrice(pence) {
return `£${(pence / 100).toFixed(2)}`;
}
example.mjs
import formatPrice, { currency as code } from "./money.mjs"; // default, and a renamed named one
import * as money from "./money.mjs"; // everything, as one object
console.log(formatPrice(1999));
console.log(code, Object.keys(money));
check("the default export is whatever the module chose", formatPrice(250), "£2.50");
check("as renames on the way in", code, "GBP");
check("a namespace import holds every export, including default", Object.keys(money).sort(), ["currency", "default"]);
What node printed, when the build ran it
node example.mjs
£19.99 GBP [ 'currency', 'default' ] ok the default export is whatever the module chose ok as renames on the way in ok a namespace import holds every export, including default
Prefer named exports. A default export can be imported under any name, so the
same function ends up called formatPrice in one file and fmt in another,
and searching the codebase for it finds half the uses. A named export has one name everywhere, and
your editor can rename it safely.
File names are exact
maths.mjs
export const add = (a, b) => a + b;
example.mjs
import { add } from "./maths";
console.log(add(1, 2));
What node printed as it died, when the build ran it
node example.mjs
node:internal/modules/esm/resolve:272
throw new ERR_MODULE_NOT_FOUND(
^
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/app/maths' imported from /app/example.mjs
at finalizeResolution (node:internal/modules/esm/resolve:272:11)
at moduleResolve (node:internal/modules/esm/resolve:879:10)
at defaultResolve (node:internal/modules/esm/resolve:1006:11)
at #cachedDefaultResolve (node:internal/modules/esm/loader:708:20)
at #resolveAndMaybeBlockOnLoaderThread (node:internal/modules/esm/loader:728:38)
at ModuleLoader.resolveSync (node:internal/modules/esm/loader:766:56)
at #resolve (node:internal/modules/esm/loader:690:17)
at ModuleLoader.getOrCreateModuleJob (node:internal/modules/esm/loader:610:35)
at ModuleJob.syncLink (node:internal/modules/esm/module_job:277:33)
at ModuleJob.link (node:internal/modules/esm/module_job:389:17) {
code: 'ERR_MODULE_NOT_FOUND',
url: 'file:///app/maths'
}
Node.js v26.7.0
An import path is a file path, and Node uses it exactly as written: "./maths" means a
file called maths, which does not exist. Always write the extension. (Bundlers and
TypeScript add it for you, which is why this surprises people coming from front-end work.) The
leading ./ matters too: without it, "maths.mjs" would mean a
package called maths.mjs in node_modules.
Imports run first
config.mjs
globalThis.order = ["config runs"];
console.log("config: running");
export const loadedAt = "before main";
example.mjs
globalThis.order.push("main: first line");
console.log("main: my first line");
import { loadedAt } from "./config.mjs";
console.log("main: config was loaded", loadedAt);
check("the import ran before main's first line", globalThis.order, ["config runs", "main: first line"]);
What node printed, when the build ran it
node example.mjs
config: running main: my first line main: config was loaded before main ok the import ran before main's first line
The import is written on line 4, but config.mjs ran before line 1 of
example.mjs. Node reads every import in a module, loads and runs those files first, and
only then runs the module's own code. So imports belong at the top of the file, where their position
matches when they actually run.
A module runs once
db.mjs
export let connections = 0;
connections++;
console.log("db: connecting (this should print once)");
a.mjs
import { connections } from "./db.mjs";
console.log("a sees", connections, "connection(s)");
b.mjs
import { connections } from "./db.mjs";
console.log("b sees", connections, "connection(s)");
example.mjs
import "./a.mjs";
import "./b.mjs";
import { connections } from "./db.mjs";
console.log("connections made:", connections);
check("db.mjs ran once, though three files imported it", connections, 1);
What node printed, when the build ran it
node example.mjs
db: connecting (this should print once) a sees 1 connection(s) b sees 1 connection(s) connections made: 1 ok db.mjs ran once, though three files imported it
The first import runs a module; every later import, from anywhere in the program, gets the same already-loaded module. So a module is a natural place to set up something shared — a database connection, a configuration object, a cache — exactly once.
Exports are live
counter.mjs
export let count = 0;
export function increment() {
count += 1;
}
example.mjs
import { count, increment } from "./counter.mjs";
console.log("before:", count);
increment();
increment();
console.log("after: ", count);
check("the importer sees the exporter's changes", count, 2);
What node printed, when the build ran it
node example.mjs
before: 0 after: 2 ok the importer sees the exporter's changes
counter.mjs
export let count = 0;
example.mjs
import { count } from "./counter.mjs";
count = 10;
What node printed as it died, when the build ran it
node example.mjs
file:///app/example.mjs:3
count = 10;
^
TypeError: Assignment to constant variable.
at file:///app/example.mjs:3:7
at ModuleJob.run (node:internal/modules/esm/module_job:569:25)
at async node:internal/modules/esm/loader:650:26
at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)
Node.js v26.7.0
An import is not a copy of a value; it is a read-only view of the exporting module's variable.
When counter.mjs changes count, every importer sees the new value — and no
importer may assign to it. This is one of the real differences from the older module system, which
the next lesson runs side by side.
Where am I? import.meta
greeting.txt
Hello from the file next door.
example.mjs
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { pathToFileURL } from "node:url";
console.log(import.meta.filename);
console.log(import.meta.dirname);
console.log(import.meta.url);
// Read a file that sits beside this module, wherever Node was started from.
const template = readFileSync(join(import.meta.dirname, "greeting.txt"), "utf8");
console.log(template.trim());
check("dirname is the folder this file is in", import.meta.dirname, dirname(import.meta.filename));
check("url is the same place, as a file: URL", import.meta.url, pathToFileURL(import.meta.filename).href);
check("and a file beside it was found", template.trim(), "Hello from the file next door.");
What node printed, when the build ran it
node example.mjs
/app/example.mjs /app file:///app/example.mjs Hello from the file next door. ok dirname is the folder this file is in ok url is the same place, as a file: URL ok and a file beside it was found
A relative path passed to readFileSync is resolved against the directory you ran
node from, not the directory the module is in. import.meta.dirname is how a
module finds files next to itself, reliably.
Importing later: import()
exporters/csv.mjs
export const name = "csv";
export const render = (rows) => rows.map((r) => r.join(",")).join("\n");
exporters/tsv.mjs
export const name = "tsv";
export const render = (rows) => rows.map((r) => r.join("\t")).join("\n");
example.mjs
const format = process.argv[2] ?? "csv";
// A static import cannot depend on a variable. import() can, and returns a promise.
const exporter = await import(`./exporters/${format}.mjs`);
console.log(exporter.render([["a", 1], ["b", 2]]));
check("the module was chosen at runtime", exporter.name, "csv");
What node printed, when the build ran it
node example.mjs
a,1 b,2 ok the module was chosen at runtime
That file also used await at its top level, outside any function, which modules
allow. It is convenient for scripts — load config, then start — and it has one cost lesson 21 shows:
a module that awaits at the top level cannot be loaded by require.
Importing JSON
config.json
{ "port": 8080, "features": ["search", "export"] }
example.mjs
import config from "./config.json" with { type: "json" };
console.log(config.port, config.features);
check("JSON imports as a plain object", config.port, 8080);
What node printed, when the build ran it
node example.mjs
8080 [ 'search', 'export' ] ok JSON imports as a plain object
The with { type: "json" } is required. Leave it off and Node refuses, deliberately —
the error catalogue shows the message.
- Nothing leaves a module unless it is exported. Prefer named exports to default ones.
- Import paths are exact: include the extension, and
./for your own files. - Imports run before the importing module's own code, and each module runs only once.
- Imports are live, read-only views of the exporter's variables.
import.meta.dirnamefinds files beside the module.import()loads a module chosen at runtime.- JSON needs
with { type: "json" }.
21 CommonJS, require, and living with both
Before ES modules existed, Node had its own module system, called
CommonJS, built on require and module.exports. Most of
npm still uses it, most tutorials older than a few years use it, and you will read it constantly —
so this lesson is about reading it, and about where the two systems meet.
require and module.exports
maths.cjs
const TAX_RATE = 0.2;
function add(a, b) {
return a + b;
}
module.exports = { add, TAX_RATE };
example.cjs
const { add, TAX_RATE } = require("./maths.cjs");
const maths = require("./maths.cjs");
console.log(add(2, 3), TAX_RATE, typeof maths);
check("require returns whatever module.exports is", maths.add(1, 1), 2);
What node printed, when the build ran it
node example.cjs
5 0.2 object ok require returns whatever module.exports is
require is an ordinary function call. It runs the file (once — the result is cached,
just as with ES modules), and returns whatever that file put in module.exports. Because
it is a function, it can go anywhere: inside an if, inside a function, with a path built
from a variable.
The exports shortcut, and its trap
good.cjs
exports.version = "1.0"; // adds a property to the exported object
bad.cjs
exports = { version: "1.0" }; // points the local name somewhere else
example.cjs
const good = require("./good.cjs");
const bad = require("./bad.cjs");
console.log("good:", good);
console.log("bad: ", bad);
check("adding to exports works", good.version, "1.0");
check("replacing exports does nothing", bad, {});
What node printed, when the build ran it
node example.cjs
good: { version: '1.0' }
bad: {}
ok adding to exports works
ok replacing exports does nothing
exports starts out as a second name for the same object as module.exports.
Adding properties to it works. Assigning a new object to it only moves the local name — what the
file exports is still module.exports, which is still empty. To export a single function
or a whole object, always write module.exports = ....
require copies; import binds
Here is lesson 20's live counter, written in CommonJS.
counter.cjs
let count = 0;
function increment() {
count += 1;
}
module.exports = { count, increment, current: () => count };
example.cjs
const { count, increment } = require("./counter.cjs");
console.log("before:", count);
increment();
increment();
console.log("after: ", count);
check("the importer's copy never changes", count, 0);
check("even though the module's own count did", require("./counter.cjs").current(), 2);
What node printed, when the build ran it
node example.cjs
before: 0 after: 0 ok the importer's copy never changes ok even though the module's own count did
module.exports = { count } puts the value of count — 0 — into a
new object at that moment. Later changes to the variable inside the module are invisible to anyone
who required it. An ES module import is a live view of the variable itself, which is why the same
program printed 2 in lesson 20.
Other differences you will meet
settings.json
{ "port": 3000 }
maths.js
exports.add = (a, b) => a + b;
example.cjs
console.log(__filename);
console.log(__dirname);
console.log(require("./settings.json").port); // JSON needs no import attribute
console.log(require("./maths").add(2, 2)); // and the extension may be left off
check("CommonJS has __dirname", typeof __dirname, "string");
check("and finds maths.js without its extension", require("./maths").add(2, 2), 4);
What node printed, when the build ran it
node example.cjs
/app/example.cjs /app 3000 4 ok CommonJS has __dirname ok and finds maths.js without its extension
example.mjs
console.log(__dirname);
What node printed as it died, when the build ran it
node example.mjs
file:///app/example.mjs:1
console.log(__dirname);
^
ReferenceError: __dirname is not defined in ES module scope
at file:///app/example.mjs:1:13
at ModuleJob.run (node:internal/modules/esm/module_job:569:25)
at async node:internal/modules/esm/loader:650:26
at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)
Node.js v26.7.0
ES modules (.mjs) | CommonJS (.cjs) | |
|---|---|---|
| Syntax | import / export | require() / module.exports |
| Imported values | live, read-only bindings | a copy, taken at require time |
| File extension in paths | required | optional |
| This file's location | import.meta.dirname | __dirname |
Top-level await | yes | no |
Top-level this | undefined | module.exports |
| Strict mode | always | only with "use strict" |
| Loading JSON | needs with { type: "json" } | require("./x.json") |
Which one is a .js file?
.mjs is always an ES module and .cjs is always CommonJS. A plain
.js file depends on the "type" field in the nearest
package.json — and, if there is none, on what the file contains.
plain.js
const os = require("node:os");
console.log("plain.js ran as CommonJS");
modern.js
import os from "node:os";
console.log("modern.js ran as an ES module");
The terminal, as the build ran it
node plain.js plain.js ran as CommonJS node modern.js modern.js ran as an ES module echo '{ "type": "module" }' > package.json node plain.js # exits 1 file:///app/plain.js:1 const os = require("node:os"); ^ ReferenceError: require is not defined in ES module scope, you can use import instead This file is being treated as an ES module because it has a '.js' file extension and '/app/package.json' contains "type": "module". To treat it as a CommonJS script, rename it to use the '.cjs' file extension. at file:///app/plain.js:1:12 at ModuleJob.run (node:internal/modules/esm/module_job:569:25) at async node:internal/modules/esm/loader:650:26 at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5) Node.js v26.7.0
With no package.json, Node treats .js as CommonJS — unless the file uses
import, export or top-level await, in which case it is quietly
re-read as a module. Once package.json says "type": "module", every
.js file in that folder is a module, and plain.js breaks — with a message
that, unusually, explains itself: it names the package.json responsible and tells you to
rename the file to .cjs. Relying on the guess is fragile; state the type.
npm init now writes "type": "commonjs". On the npm
that built this page, a new project starts as CommonJS, explicitly — lesson 22 shows the file it
writes. If you mean to write import, change it to "module" before you
write any code.
Crossing between the two
legacy.cjs
exports.version = "2.1";
exports.slugify = (text) => text.toLowerCase().replace(/\s+/g, "-");
example.mjs
import legacy from "./legacy.cjs"; // a CommonJS file's module.exports
import { slugify } from "./legacy.cjs"; // named, when Node can detect it
import { createRequire } from "node:module";
const require = createRequire(import.meta.url); // a real require, inside a module
const alsoLegacy = require("./legacy.cjs");
console.log(slugify("Hello World"), legacy.version, alsoLegacy === legacy);
check("default import is module.exports", legacy.version, "2.1");
check("named import worked for exports.slugify = ...", slugify("A B"), "a-b");
check("createRequire returns the same cached module", alsoLegacy === legacy, true);
What node printed, when the build ran it
node example.mjs
hello-world 2.1 true ok default import is module.exports ok named import worked for exports.slugify = ... ok createRequire returns the same cached module
modern.mjs
export const greet = (name) => `hi ${name}`;
export default "the default";
example.cjs
const lib = require("./modern.mjs");
console.log(lib);
console.log(lib.greet("Ada"), lib.default);
check("require() of an ES module returns its namespace", typeof lib.greet, "function");
check("with the default export under .default", lib.default, "the default");
What node printed, when the build ran it
node example.cjs
[Module: null prototype] {
__esModule: true,
default: 'the default',
greet: [Function: greet]
}
hi Ada the default
ok require() of an ES module returns its namespace
ok with the default export under .default
Node prints the result as a [Module: null prototype] with an extra
__esModule: true marker, which is how code written for bundlers recognises a converted
module. That whole crossing is recent. For most of Node's history, require could not load an ES
module at all, and a CommonJS project that needed one had to use await import(). On this
Node it just works — with one exception: a module that uses top-level await cannot finish
synchronously, so require refuses it.
db.mjs
export const db = await Promise.resolve({ connected: true });
example.cjs
const { db } = require("./db.mjs");
console.log(db);
What node printed as it died, when the build ran it
node example.cjs
node:internal/modules/esm/module_job:314
throw new ERR_REQUIRE_ASYNC_MODULE(filename, parent, locations);
^
Error [ERR_REQUIRE_ASYNC_MODULE]: require() cannot be used on an ESM graph with top-level await. Use import() instead. To see where the top-level await comes from, use --experimental-print-required-tla.
Required module: /app/db.mjs
Require stack:
- /app/example.cjs
at ModuleJobSync.throwIfAsyncGraph (node:internal/modules/esm/module_job:314:11)
at ModuleJobSync.runSync (node:internal/modules/esm/module_job:670:10)
at ModuleLoader.importSyncForRequire (node:internal/modules/esm/loader:350:47)
at loadESMFromCJS (node:internal/modules/cjs/loader:1752:24)
at Module._compile (node:internal/modules/cjs/loader:1916:5)
at Object..js (node:internal/modules/cjs/loader:2074:10)
at Module.load (node:internal/modules/cjs/loader:1656:32)
at Module._load (node:internal/modules/cjs/loader:1448:12)
at wrapModuleLoad (node:internal/modules/cjs/loader:261:19)
at Module.require (node:internal/modules/cjs/loader:1679:12) {
code: 'ERR_REQUIRE_ASYNC_MODULE'
}
Node.js v26.7.0
- CommonJS:
require()returns a file'smodule.exports, cached after the first time. exports.x = ...works;exports = ...does not. Usemodule.exportsto export one thing.requirecopies values;importgives live bindings..mjsis a module,.cjsis CommonJS, and.jsfollows"type"inpackage.json. Set it explicitly.- Modules can import CommonJS; CommonJS can
requiremodules, except ones that use top-levelawait.
22 package.json, scripts, and node --run
A package.json file turns a folder into a project. It records
the project's name and version, which module system its .js files use, the packages it
depends on, and the commands people run to work on it. It is plain JSON, and nothing in it needs the
internet.
Making one
The terminal, as the build ran it
npm init -y Wrote to /app/package.json: { "name": "app", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], "author": "", "license": "ISC", "type": "commonjs" } npm pkg set type=module description="An example project" npm pkg get name type description { "name": "app", "type": "module", "description": "An example project" }
npm init -y writes a starting file with defaults for everything. Three of those
defaults are worth changing immediately. The "type" is "commonjs" — set it
to "module" if you are going to write import, as this course does (lesson 21
showed why it matters). The "test" script is a placeholder that fails on purpose. And
"main" points at an index.js that does not exist; lesson 49 replaces it with
"exports". npm pkg set edits the file for you without breaking the JSON.
The fields that matter
package.json
{
"name": "invoice-service",
"version": "1.4.0",
"private": true,
"type": "module",
"engines": { "node": ">=22" },
"scripts": {
"start": "node server.mjs",
"dev": "node --watch server.mjs",
"test": "node --test"
},
"dependencies": {},
"devDependencies": {}
}
example.mjs
import { readFileSync } from "node:fs";
const pkg = JSON.parse(readFileSync("package.json", "utf8"));
for (const [field, value] of Object.entries(pkg)) {
console.log(field.padEnd(16), JSON.stringify(value));
}
check("a version is three numbers", /^\d+\.\d+\.\d+$/.test(pkg.version), true);
check("private stops it being published by accident", pkg.private, true);
What node printed, when the build ran it
node example.mjs
name "invoice-service"
version "1.4.0"
private true
type "module"
engines {"node":">=22"}
scripts {"start":"node server.mjs","dev":"node --watch server.mjs","test":"node --test"}
dependencies {}
devDependencies {}
ok a version is three numbers
ok private stops it being published by accident
- name and version identify the project. Versions follow
semantic versioning,
MAJOR.MINOR.PATCH, which lesson 23 explains. - private: true makes
npm publishrefuse to publish it. Set it on every application — anything that is not a library meant for others. - type decides whether
.jsfiles are modules. - engines records which Node versions the project supports. npm warns when the installed Node does not match.
- scripts are named commands. dependencies and devDependencies are the packages it needs to run, and to be worked on.
Scripts
package.json
{
"name": "app",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node server.mjs",
"greet": "node greet.mjs"
}
}
server.mjs
console.log("server starting (pretend)");
greet.mjs
const args = process.argv.slice(2);
const loud = args.includes("--loud");
const name = args.find((a) => !a.startsWith("--")) ?? "world";
console.log(loud ? `HELLO, ${name.toUpperCase()}!` : `hello, ${name}`);
The terminal, as the build ran it
npm run Lifecycle scripts included in app@1.0.0: start node server.mjs available via `npm run`: greet node greet.mjs npm start > app@1.0.0 start > node server.mjs server starting (pretend) npm run greet -- --loud Ada > app@1.0.0 greet > node greet.mjs --loud Ada HELLO, ADA!
A script is a shell command with a name. npm run on its own lists them, and
npm run name runs one. start and test are special enough to have
shortcuts: npm start, npm test. Everything after -- is passed
through to the script, which is how --loud Ada reached greet.mjs.
Scripts are the project's documentation for "how do I run this". Anyone who clones it can type
npm run and see every command without reading a README, and the exact flags live in one
place rather than in everyone's memory.
node --run, and how it differs
Node can run a package.json script itself, without starting npm, which makes it
noticeably faster. It is not a drop-in replacement, and the difference is easy to miss.
package.json
{
"name": "invoice-service",
"version": "1.4.0",
"scripts": {
"prestart": "echo checking the config first",
"start": "echo starting",
"whoami": "echo this is $npm_package_name version $npm_package_version"
}
}
The terminal, as the build ran it
npm start > invoice-service@1.4.0 prestart > echo checking the config first checking the config first > invoice-service@1.4.0 start > echo starting starting node --run start starting npm run whoami > invoice-service@1.4.0 whoami > echo this is $npm_package_name version $npm_package_version this is invoice-service version 1.4.0 node --run whoami this is version
Two things differ, both visible above. npm start ran prestart first,
because npm runs any prename and postname scripts around a
script automatically; node --run does not. And npm sets environment variables such as
$npm_package_name for every script; node --run does not, so the second
whoami printed blanks. If your scripts rely on either, keep using npm run.
A script can find its tools
package.json
{
"name": "app",
"version": "1.0.0",
"scripts": { "hello": "hello --from-a-script" }
}
node_modules/.bin/hello
#!/bin/sh
echo "a tool installed in node_modules/.bin, run with: $*"
The terminal, as the build ran it
chmod +x node_modules/.bin/hello npm run hello > app@1.0.0 hello > hello --from-a-script a tool installed in node_modules/.bin, run with: --from-a-script which hello # exits 1
Packages that install a command put it in node_modules/.bin. Inside a script, that
folder is added to the front of the PATH, so a script can call hello
directly. At your own prompt it is not on the PATH: which, which prints
where a command lives, finds nothing and exits 1. That is what npx is for, in the next
lesson.
One detail about that panel. The file in node_modules/.bin was
written by the panel itself, and made executable with chmod +x, to show the mechanism
without installing anything. Real packages put it there when npm installs them — which the next lesson does, with a
package that exists only on this disk.
npm init -ymakes apackage.json. Change"type"to"module"if you writeimport."private": trueon every application, so it cannot be published by accident.- Scripts are named commands:
npm runlists them, and--passes arguments through. node --runis faster, but skipspre/postscripts and npm's$npm_package_*variables.- Inside a script,
node_modules/.binis on thePATH. Outside, it is not.
23 npm, node_modules and version ranges
npm is the tool that downloads other people's code into your project and
keeps track of which versions you depend on. It is also a registry of millions of packages. This
lesson is about the tool: what an install actually changes on disk, what the lock file is for, and
what the version ranges in package.json mean.
This page has no network
package.json
{ "name": "app", "version": "1.0.0", "private": true, "type": "module" }
The terminal, as the build ran it
npm install left-pad # exits 1
npm error code ENOTCACHED
npm error request to http://127.0.0.1:9/left-pad failed: cache mode is 'only-if-cached' but no cached response is available.
npm error A complete log of this run can be found in: /home/you/.npm/_logs/2026-09-30T00_44_30_219Z-debug-0.log
Every command on this site runs with npm pointed at an address where nothing is listening, so an
install from the registry fails — with npm's own ENOTCACHED: it looked in its cache,
found nothing, and was not allowed to go online. On your machine that command downloads the package.
Everything else npm does to your project, it does the same way whether the package came from the
registry or from your own disk. So the rest of this lesson uses a package that lives next door, in
../greet.
What an install changes
../greet/package.json
{
"name": "greet",
"version": "1.2.0",
"type": "module",
"exports": "./index.js",
"bin": { "greet": "./cli.js" }
}
../greet/index.js
export const hello = (name) => `Hello, ${name}!`;
../greet/cli.js
#!/usr/bin/env node
import { hello } from "./index.js";
console.log(hello(process.argv[2] ?? "world"));
package.json
{ "name": "app", "version": "1.0.0", "private": true, "type": "module" }
main.mjs
import { hello } from "greet";
console.log(hello("Ada"));
The terminal, as the build ran it
npm install ../greet added 1 package in 323ms cat package.json {"name":"app","version":"1.0.0","private":true,"type":"module","dependencies":{"greet":"file:../greet"}} ls node_modules greet readlink node_modules/greet ../../greet node main.mjs Hello, Ada! npm ls app@1.0.0 /app └── greet@1.2.0 -> ./../greet
Three things happened. package.json gained a "dependencies" entry
recording what the project needs. A node_modules folder appeared, holding the package
(here a link to the folder, since it is local). And a package-lock.json was written —
more on that below. Then import ... from "greet" worked, because a bare name like
"greet" means "look in node_modules".
What a package really is
../greet/package.json
{
"name": "greet",
"version": "1.2.0",
"type": "module",
"exports": "./index.js",
"bin": { "greet": "./cli.js" }
}
../greet/index.js
export const hello = (name) => `Hello, ${name}!`;
../greet/cli.js
#!/usr/bin/env node
import { hello } from "./index.js";
console.log(hello(process.argv[2] ?? "world"));
package.json
{ "name": "app", "version": "1.0.0", "private": true, "type": "module" }
The terminal, as the build ran it
cd ../greet && npm pack npm notice npm notice 📦 greet@1.2.0 npm notice Tarball Contents npm notice 105B cli.js npm notice 50B index.js npm notice 125B package.json npm notice Tarball Details npm notice name: greet npm notice version: 1.2.0 npm notice filename: greet-1.2.0.tgz npm notice package size: 346 B npm notice unpacked size: 280 B npm notice shasum: bc37abc7ad7e7fbb20044ae2dfd4a4a1cf9c887e npm notice integrity: sha512-rw2+ET7RxMY4r[...]QaTXsNJMJPidg== npm notice total files: 3 npm notice greet-1.2.0.tgz cd ../app && npm install ../greet/greet-1.2.0.tgz added 1 package in 364ms ls node_modules/greet cli.js index.js package.json cat package.json {"name":"app","version":"1.0.0","private":true,"type":"module","dependencies":{"greet":"file:../greet/greet-1.2.0.tgz"}}
npm pack makes the same .tgz file that npm publish would
upload, and that the registry hands to everyone who installs it. Installing that file unpacks it into
node_modules/greet — real files this time, not a link. When you install a package from
the registry, this is all that happens: download a tarball like this one, unpack it, and repeat for
its dependencies.
Commands from packages: npx
../greet/package.json
{
"name": "greet",
"version": "1.2.0",
"type": "module",
"exports": "./index.js",
"bin": { "greet": "./cli.js" }
}
../greet/index.js
export const hello = (name) => `Hello, ${name}!`;
../greet/cli.js
#!/usr/bin/env node
import { hello } from "./index.js";
console.log(hello(process.argv[2] ?? "world"));
package.json
{ "name": "app", "version": "1.0.0", "private": true, "type": "module" }
The terminal, as the build ran it
chmod +x ../greet/cli.js npm install ../greet added 1 package in 326ms ls node_modules/.bin greet npx greet Grace Hello, Grace!
A package can declare commands in its "bin" field, and npm links them into
node_modules/.bin. npx greet runs a command from there. (Given a package
that is not installed, npx would download it first — which, like any install, needs the
network.)
How Node finds a package
node_modules/shared/package.json
{ "name": "shared", "version": "1.0.0", "main": "index.js" }
node_modules/shared/index.js
module.exports = "found in /app/node_modules";
src/jobs/nightly/run.cjs
console.log(require("shared"));
console.log(module.paths.slice(0, 5));
check("found by walking up from this file's folder", require("shared"), "found in /app/node_modules");
What node printed, when the build ran it
node src/jobs/nightly/run.cjs
found in /app/node_modules [ '/app/src/jobs/nightly/node_modules', '/app/src/jobs/node_modules', '/app/src/node_modules', '/app/node_modules', '/node_modules' ] ok found by walking up from this file's folder
A bare name is looked for in a node_modules folder next to the importing file, then in
its parent, and so on up to the root of the disk — module.paths lists the exact search
order. That is why one node_modules at the top of a project serves every file inside
it, however deeply nested.
Version ranges
A dependency from the registry is recorded with a range, not a single version:
"express": "^5.1.0". Versions follow semantic versioning,
MAJOR.MINOR.PATCH: a patch fixes bugs, a minor release adds things without breaking
anything, and a major release may break things. The caret means "this version, or any later one with
the same major number".
ranges.cjs
const semver = require(process.env.SEMVER);
const cases = [
["^1.2.0", ["1.2.0", "1.2.9", "1.9.0", "2.0.0", "1.1.9"]],
["~1.2.0", ["1.2.0", "1.2.9", "1.3.0"]],
["1.2.0", ["1.2.0", "1.2.1"]],
["^0.2.3", ["0.2.3", "0.2.9", "0.3.0"]],
];
for (const [range, versions] of cases) {
const verdicts = versions.map((v) => `${v} ${semver.satisfies(v, range) ? "yes" : "no "}`);
console.log(range.padEnd(7), "|", verdicts.join(" "));
}
The terminal, as the build ran it
export SEMVER="$(npm root -g)/npm/node_modules/semver" node ranges.cjs ^1.2.0 | 1.2.0 yes 1.2.9 yes 1.9.0 yes 2.0.0 no 1.1.9 no ~1.2.0 | 1.2.0 yes 1.2.9 yes 1.3.0 no 1.2.0 | 1.2.0 yes 1.2.1 no ^0.2.3 | 0.2.3 yes 0.2.9 yes 0.3.0 no
| Range | Means | Use it for |
|---|---|---|
^1.2.0 | 1.2.0 up to, not including, 2.0.0 | the default; takes fixes and features |
~1.2.0 | 1.2.0 up to, not including, 1.3.0 | patches only |
1.2.0 | exactly that version | when you must pin |
^0.2.3 | 0.2.3 up to 0.3.0 — below 1.0, the minor number is treated as major | early packages, where anything can break |
That table is the one npm's own code just printed, row by row. The last row surprises people: for a
package still at 0.x, a caret does not allow the next minor version, because before 1.0
every minor release is allowed to break things.
The lock file
A range lets different installs pick different versions: install today and get 1.2.9, install next
month and get 1.4.0. package-lock.json fixes that. It records the exact version of every
package that was installed — including the dependencies of your dependencies — so that everyone, and
every server, gets the same tree.
../greet/package.json
{
"name": "greet",
"version": "1.2.0",
"type": "module",
"exports": "./index.js",
"bin": { "greet": "./cli.js" }
}
../greet/index.js
export const hello = (name) => `Hello, ${name}!`;
../greet/cli.js
#!/usr/bin/env node
import { hello } from "./index.js";
console.log(hello(process.argv[2] ?? "world"));
package.json
{ "name": "app", "version": "1.0.0", "private": true, "type": "module" }
The terminal, as the build ran it
npm install ../greet added 1 package in 330ms cat package-lock.json { "name": "app", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "app", "version": "1.0.0", "dependencies": { "greet": "file:../greet" } }, "../greet": { "version": "1.2.0", "bin": { "greet": "cli.js" } }, "node_modules/greet": { "resolved": "../greet", "link": true } } } rm -rf node_modules npm ci added 1 package in 320ms ls node_modules greet
npm ci ("clean install") deletes node_modules and installs exactly what
the lock file says, and fails if package.json and the lock disagree, rather than
quietly updating it. It is what continuous integration and deployments should run. Commit
package.json and package-lock.json; never commit node_modules,
which can always be rebuilt from them.
Install scripts, and what npm says about them
A package can include scripts that run when it is installed — preinstall,
install, postinstall. They exist for legitimate reasons, such as compiling
native code, and they are also the most common way malicious packages attack a machine: the code runs
the moment you type npm install, before you have used the package at all.
../greet/package.json
{
"name": "greet",
"version": "1.2.0",
"type": "module",
"exports": "./index.js",
"scripts": {
"postinstall": "node -e \"require('fs').writeFileSync(process.env.INIT_CWD + '/postinstall-ran.txt', '')\""
}
}
../greet/index.js
export const hello = (name) => `Hello, ${name}!`;
package.json
{ "name": "app", "version": "1.0.0", "private": true, "type": "module" }
The terminal, as the build ran it
npm install ../greet added 1 package in 414ms npm warn install-scripts 1 package has install scripts not yet covered by allowScripts: npm warn install-scripts greet@1.2.0 (postinstall: node -e "require('fs').writeFileSync(process.env.INIT_CWD + '/postinstall-ran.txt', '')") npm warn install-scripts npm warn install-scripts Run `npm install-scripts ls` to review, or `npm install-scripts approve <pkg>` to allow. ls node_modules package-lock.json package.json postinstall-ran.txt
Read the warning, then look at the ls. npm said the package "has install scripts not
yet covered by allowScripts", and npm help install-scripts, on the same npm, says
dependency install scripts "are blocked by default". But postinstall-ran.txt is there:
the script ran. On this version, the default only warns. Two flags actually stop it.
../greet/package.json
{
"name": "greet",
"version": "1.2.0",
"type": "module",
"exports": "./index.js",
"scripts": {
"postinstall": "node -e \"require('fs').writeFileSync(process.env.INIT_CWD + '/postinstall-ran.txt', '')\""
}
}
../greet/index.js
export const hello = (name) => `Hello, ${name}!`;
package.json
{ "name": "app", "version": "1.0.0", "private": true, "type": "module" }
The terminal, as the build ran it
npm install ../greet --strict-allow-scripts # exits 1 npm error code ESTRICTALLOWSCRIPTS npm error --strict-allow-scripts: 1 package(s) have install scripts not covered by allowScripts: npm error greet@1.2.0 (postinstall: node -e "require('fs').writeFileSync(process.env.INIT_CWD + '/postinstall-ran.txt', '')") npm error Approve them with `npm install-scripts approve`, deny them with `npm install-scripts deny`, or bypass this check with `--dangerously-allow-all-scripts`. npm error A complete log of this run can be found in: /home/you/.npm/_logs/2026-09-30T00_44_36_170Z-debug-0.log ls package.json npm install ../greet --ignore-scripts added 1 package in 308ms ls node_modules package-lock.json package.json npm install-scripts approve greet Approved file:../../greet: added file:../../greet npm install --strict-allow-scripts up to date in 396ms ls node_modules package-lock.json package.json postinstall-ran.txt cat package.json {"name":"app","version":"1.0.0","private":true,"type":"module","dependencies":{"greet":"file:../greet"},"allowScripts":{"file:../../greet":true}}
--strict-allow-scripts refuses to install at all while any package's scripts are
unapproved — nothing was installed, and nothing ran. --ignore-scripts installs the
package and skips every install script. npm install-scripts approve (which needs the
package installed first) records a decision in package.json under
"allowScripts", after which a strict install lets that one package's script run —
postinstall-ran.txt appears only at the end.
Check the behaviour, not the documentation. The help text and the default
disagreed on the npm that built this page, and the difference is whether untrusted code runs on
your machine. Put strict-allow-scripts=true in your project's .npmrc, or
your CI's, and every new install script becomes a decision someone has to make.
The commands that need the registry
npm install express # add a dependency
npm install -D typescript # a dev dependency: needed to build or test, not to run
npm outdated # what has newer versions
npm update # move within your ranges
npm audit # known vulnerabilities in your tree
npm view express versions # what the registry has
Each of these talks to the npm registry, and this site has no network: npm is pointed at an address nothing answers on, and the first panel on this page shows what happens when a command tries. Every local part of what they do is run for real above.
- An install records a dependency in
package.json, unpacks it intonode_modules, and writespackage-lock.json. - A package is a tarball.
npm packmakes the onenpm publishwould upload. - Bare imports are looked for in
node_modules, walking up the folders. ^1.2.0allows anything below 2.0.0;~1.2.0anything below 1.3.0; below 1.0, a caret is as narrow as a tilde.- Commit the lock file, not
node_modules.npm ciinstalls exactly what it says. - Install scripts run by default on this npm, with a warning.
--strict-allow-scriptsandnpm install-scripts approvemake each one a decision.