Emmanuel Akinfulubi

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

Two files, one import

ran at build time

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

default, as, and * as

ran at build time

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

Leaving off the extension

dies with ERR_MODULE_NOT_FOUND

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

Imports are hoisted

ran at build time

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

Shared by everyone who imports it

ran at build time

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

A live binding

ran at build time

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

but only the exporter can change it

dies with TypeError

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

import.meta

ran at build time

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()

Dynamic import

ran at build time

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

import with { type: "json" }

ran at build time

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.dirname finds 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

The same module, in CommonJS

ran at build time

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

exports.x = versus exports =

ran at build time

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.

The counter, in CommonJS

ran at build time

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

What CommonJS gives you

ran at build time

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

__dirname in an ES module

dies with ReferenceError

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)
Syntaximport / exportrequire() / module.exports
Imported valueslive, read-only bindingsa copy, taken at require time
File extension in pathsrequiredoptional
This file's locationimport.meta.dirname__dirname
Top-level awaityesno
Top-level thisundefinedmodule.exports
Strict modealwaysonly with "use strict"
Loading JSONneeds 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.

The same .js files, before and after a type field

ran at build time

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

An ES module using CommonJS

ran at build time

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

CommonJS requiring an ES module

ran at build time

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.

require() of a module that awaits

dies with ERR_REQUIRE_ASYNC_MODULE

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's module.exports, cached after the first time.
  • exports.x = ... works; exports = ... does not. Use module.exports to export one thing.
  • require copies values; import gives live bindings.
  • .mjs is a module, .cjs is CommonJS, and .js follows "type" in package.json. Set it explicitly.
  • Modules can import CommonJS; CommonJS can require modules, except ones that use top-level await.

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

npm init, and editing the result

ran at build time

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

A realistic package.json, read back

ran at build time

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 publish refuse to publish it. Set it on every application — anything that is not a library meant for others.
  • type decides whether .js files 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

npm run

ran at build time

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.

The same scripts, two ways

ran at build time

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

node_modules/.bin is on the PATH, but only inside scripts

ran at build time

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 -y makes a package.json. Change "type" to "module" if you write import.
  • "private": true on every application, so it cannot be published by accident.
  • Scripts are named commands: npm run lists them, and -- passes arguments through.
  • node --run is faster, but skips pre/post scripts and npm's $npm_package_* variables.
  • Inside a script, node_modules/.bin is on the PATH. 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

Installing from the registry, with no registry

ran at build time

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

Installing a package from a folder

ran at build time

../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

A tarball, which is what the registry sends

ran at build time

../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

A package that installs a command

ran at build time

../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, searched upwards

ran at build time

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".

The rules, checked with npm’s own semver code

ran at build time

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
RangeMeansUse it for
^1.2.01.2.0 up to, not including, 2.0.0the default; takes fixes and features
~1.2.01.2.0 up to, not including, 1.3.0patches only
1.2.0exactly that versionwhen you must pin
^0.2.30.2.3 up to 0.3.0 — below 1.0, the minor number is treated as majorearly 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.

package-lock.json, and npm ci

ran at build time

../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.

A dependency with a postinstall script

ran at build time

../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.

Blocking install scripts for real

ran at build time

../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

Everyday npm, online

shown, not run
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 into node_modules, and writes package-lock.json.
  • A package is a tarball. npm pack makes the one npm publish would upload.
  • Bare imports are looked for in node_modules, walking up the folders.
  • ^1.2.0 allows anything below 2.0.0; ~1.2.0 anything below 1.3.0; below 1.0, a caret is as narrow as a tilde.
  • Commit the lock file, not node_modules. npm ci installs exactly what it says.
  • Install scripts run by default on this npm, with a warning. --strict-allow-scripts and npm install-scripts approve make each one a decision.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.