Modules and npm
Splitting code into modules with import and export, the older CommonJS require, and using npm to install packages: package.json, lock files, version ranges, scripts, and supply-chain risks.
Why modules
A module is a file of code that other code can import. Modules let you split a program into files that each do one job, reuse code between programs, and keep names apart: a variable declared at the top of a module is visible only inside that module, unless the module explicitly exports it.
JavaScript was born without modules. Early web pages loaded several
<script> files that all shared one global scope,
so a variable named total in one file could silently
overwrite total in another. Node.js (2009) adopted a
module system of its own, CommonJS. ECMAScript 2015 (ES2015) then
added a module system to the language itself. Both are still in use,
so you need to recognize both.
ES modules: import and export
An ES module (ECMAScript module,
often written ESM) is a file that uses the standard
import and export syntax. ES modules
always run in strict mode, have their own top-level scope, are
loaded once however often they are imported, and may use
await at the top level.
Named and default exports
A module can export values in two ways. A
named export shares a value
under its own name, and the importer must use that name (in curly
braces). A module can have any number of them. A
default export is the one main
value of a module, marked with export default; the
importer chooses any name for it, without curly braces. A module has
at most one. Here is a small module with both:
// tariffs.mjs
export const VAT_RATE = 0.21;
export function withVat(amount) {
return Math.round(amount * (1 + VAT_RATE) * 100) / 100;
}
export default function formatEuro(amount) {
return `EUR ${amount.toFixed(2)}`;
}
A second file in the same folder imports them:
// main.mjs
import formatEuro, { withVat, VAT_RATE } from "./tariffs.mjs";
console.log(`VAT: ${VAT_RATE * 100}%`);
console.log(formatEuro(withVat(111.24)));
node main.mjs
VAT: 21% EUR 134.60
The most common forms of import:
| Syntax | What you get |
|---|---|
import { withVat } from "./tariffs.mjs"; |
one named export |
import { withVat as addVat } from "./tariffs.mjs";
|
a named export under another name |
import formatEuro from "./tariffs.mjs"; |
the default export, under a name you choose |
import * as tariffs from "./tariffs.mjs";
|
everything, as properties of one namespace object |
const mod = await import("./tariffs.mjs");
|
dynamic import: loads the module only when this line runs and returns a promise |
Import paths
In ES modules, a relative path must include the file extension:
"./tariffs.mjs", not "./tariffs". Node.js
reports a missing extension as ERR_MODULE_NOT_FOUND. A
path without ./ or ../, such as
"report-lib", is a bare specifier:
Node.js looks it up as a package in node_modules.
Built-in Node.js modules use the node: prefix, which
makes clear that they do not come from npm:
import path from "node:path";
import { basename, extname } from "node:path";
import * as util from "node:util";
const file = path.join("reports", "2026", "meter-readings.csv");
console.log(file);
console.log(basename(file), extname(file));
console.log(util.format("%s readings, %d failed", 1200, 3));
console.log(typeof require, typeof module);
console.log(this);
reports/2026/meter-readings.csv meter-readings.csv .csv 1200 readings, 3 failed undefined undefined undefined
The last two lines show what an ES module does not have:
require and module are CommonJS names, and
this at the top level is undefined. For
the module's own location, Node.js offers
import.meta.dirname and
import.meta.filename in place of CommonJS's
__dirname and __filename.
Modules in the browser
In a web page, a script becomes a module with
type="module". Its own import statements
then load further modules by URL:
<script type="module" src="js/main.js"></script>
Module scripts are deferred automatically (they run after the HTML
has been parsed), are fetched with CORS rules, and therefore usually
do not work from a page opened as a file:// address;
serve the folder with a local web server instead. Browsers do not
know about node_modules, so a bare specifier such as
"report-lib" only works with an
import map (a
<script type="importmap"> that maps names to
URLs, supported in all major browsers since 2023) or with a
bundler such as Vite, esbuild or webpack, which
combines your modules and packages into a few files for the browser.
CommonJS: require and module.exports
CommonJS (CJS) is the module system
Node.js used from its start: a module exports by assigning to
module.exports, and another file loads it with the
function require(), which reads and runs the file
synchronously and returns its exports. Much existing Node.js code
and many npm packages still use it.
// tariffs.cjs
const VAT_RATE = 0.21;
function withVat(amount) {
return Math.round(amount * (1 + VAT_RATE) * 100) / 100;
}
module.exports = { VAT_RATE, withVat };
// main.cjs
const { withVat } = require("./tariffs.cjs");
console.log(withVat(111.24));
Built-in modules work the same way. This is the path example from above, as CommonJS:
const path = require("node:path");
const { basename, extname } = require("node:path");
const file = path.join("reports", "2026", "meter-readings.csv");
console.log(file);
console.log(basename(file), extname(file));
console.log(typeof require, typeof module);
console.log(this === module.exports);
reports/2026/meter-readings.csv meter-readings.csv .csv function object true
How Node.js decides which system a file uses
-
A
.mjsfile is always an ES module; a.cjsfile is always CommonJS. -
A
.jsfile is an ES module when the nearestpackage.jsoncontains"type": "module", and CommonJS when it contains"type": "commonjs". -
A
.jsfile without a"type"is treated as CommonJS, but recent Node.js releases look at the code first: if it containsimportorexport, they run it as an ES module, and print a warning about the missing type when apackage.jsonexists. Set"type"explicitly, so nobody has to rely on that guess.
| ES modules | CommonJS | |
|---|---|---|
| Syntax | import / export |
require() / module.exports |
| Standard | ECMAScript (since ES2015); browsers and Node.js | Node.js convention; not in browsers |
| Loading | imports are resolved before the code runs | require() runs when that line is reached |
Top-level await |
yes | no |
| Strict mode | always | only with "use strict" |
| File location |
import.meta.dirname,
import.meta.filename
|
__dirname, __filename |
The two systems can work together. An ES module can
import a CommonJS module. The other direction used to
be impossible, but require() of an ES module now works
without a flag in Node.js 20.19 and later and 22.12 and later, and
is stable since Node.js 25.4, as long as the ES module does not use
top-level await. For new projects, choose ES modules:
set "type": "module" in package.json.
npm: the package manager
npm is the package manager that comes
with Node.js: a command-line tool that downloads packages from the
public npm registry (npmjs.com, with millions of packages), installs
them into your project, and keeps track of them. Other package
managers such as pnpm and Yarn use the same registry and the same
package.json; this page sticks to npm.
npm init -y # create package.json with default values
npm install report-lib # add a dependency (short: npm i report-lib)
npm install -D eslint # add a development-only dependency
npm uninstall report-lib # remove it again
npm outdated # list dependencies that have newer versions
npm update # update within the allowed version ranges
added 3 packages, and audited 4 packages in 2s found 0 vulnerabilities
npm install puts the package, and every package it
needs in turn, in a folder named node_modules inside
your project. Those indirect packages are transitive
dependencies, and they often far outnumber the ones you chose yourself. npm
shares one copy between packages where the version ranges allow it:
package.json
package.json is the
JSON file in a project's root folder that describes the project for
npm and Node.js: its name and version, whether
.js files are ES modules, the commands it offers, and
the packages it depends on, each with a version range. A typical
one:
{
"name": "meter-report",
"version": "1.2.0",
"private": true,
"type": "module",
"scripts": {
"start": "node src/main.js",
"test": "node --test",
"lint": "eslint ."
},
"dependencies": {
"report-lib": "^2.1.0",
"date-kit": "~3.5.0"
},
"devDependencies": {
"eslint": "^10.0.0"
},
"engines": {
"node": ">=22"
}
}
-
dependenciesare needed to run the program;devDependenciesonly to develop it (linters, test tools).npm install --omit=devskips the latter on a production server. -
"private": truestops the project from being published to the registry by accident. -
enginesdocuments which Node.js versions the project supports.
Because it is plain JSON, any program can read it:
const packageJson = `{
"name": "meter-report",
"version": "1.2.0",
"type": "module",
"scripts": { "start": "node src/main.js", "test": "node --test" },
"dependencies": { "report-lib": "^2.1.0", "date-kit": "~3.5.0" },
"devDependencies": { "eslint": "^10.0.0" }
}`;
const pkg = JSON.parse(packageJson);
console.log(`${pkg.name}@${pkg.version}, module type: ${pkg.type}`);
console.log("Scripts:", Object.keys(pkg.scripts).join(", "));
for (const group of ["dependencies", "devDependencies"]) {
for (const [name, range] of Object.entries(pkg[group])) {
console.log(` ${group.padEnd(16)} ${name.padEnd(10)} ${range}`);
}
}
meter-report@1.2.0, module type: module Scripts: start, test dependencies report-lib ^2.1.0 dependencies date-kit ~3.5.0 devDependencies eslint ^10.0.0
Scripts
The scripts section gives a project's common commands
short names, so every developer and every CI pipeline runs them the
same way. npm run lint runs the
lint script; npm test and
npm start work without run. Inside a
script, the tools in node_modules/.bin are on the path,
so "lint": "eslint ." finds the project's own ESLint
without a global install.
npm test
npm run lint
Version ranges and lock files
npm packages follow
semantic versioning: MAJOR.MINOR.PATCH, where a new major version may break your code
and minor and patch versions should not. A
version range is the rule in
package.json that says which versions of a dependency
are acceptable:
| Range | Means | Accepts |
|---|---|---|
^2.1.0 |
compatible: same major version | >=2.1.0 and <3.0.0 |
~2.1.0 |
approximately: same minor version | >=2.1.0 and <2.2.0 |
^0.3.1 |
below 1.0, the caret keeps the minor fixed | >=0.3.1 and <0.4.0 |
2.1.0 |
exactly this version | 2.1.0 only |
npm install <package> saves a caret range by
default. A simplified version of the matching rules shows which
published versions each range lets in; npm picks the highest one,
unless the lock file already pins a version:
// A simplified range check for plain MAJOR.MINOR.PATCH versions.
// (npm's real rules also cover pre-releases, "x", ">=", "||" and more.)
function parse(version) {
return version.split(".").map(Number);
}
function isAtLeast(a, b) {
for (let i = 0; i < 3; i++) {
if (a[i] !== b[i]) return a[i] > b[i];
}
return true; // equal
}
function satisfies(version, range) {
const operator = range[0] === "^" || range[0] === "~" ? range[0] : "";
if (operator === "") return version === range; // exact version
const v = parse(version);
const base = parse(range.slice(1));
const [major, minor] = v;
const [bMajor, bMinor] = base;
if (!isAtLeast(v, base)) return false;
if (operator === "~") return major === bMajor && minor === bMinor;
if (bMajor > 0) return major === bMajor; // ^1.4.0: same major
return major === 0 && minor === bMinor; // ^0.3.1: same minor
}
const published = ["1.4.0", "1.4.2", "1.5.0", "1.9.3", "2.0.0", "2.1.0"];
for (const range of ["^1.4.0", "~1.4.0", "1.4.0"]) {
const allowed = published.filter((v) => satisfies(v, range));
console.log(`${range.padEnd(7)} allows ${allowed.join(", ")}`);
console.log(` npm picks ${allowed.at(-1)}`);
}
console.log("^0.3.1 allows 0.3.7?", satisfies("0.3.7", "^0.3.1"));
console.log("^0.3.1 allows 0.4.0?", satisfies("0.4.0", "^0.3.1"));
^1.4.0 allows 1.4.0, 1.4.2, 1.5.0, 1.9.3
npm picks 1.9.3
~1.4.0 allows 1.4.0, 1.4.2
npm picks 1.4.2
1.4.0 allows 1.4.0
npm picks 1.4.0
^0.3.1 allows 0.3.7? true
^0.3.1 allows 0.4.0? false
The lock file
Ranges make it easy to receive fixes, but they also mean that two
installs a month apart can produce different code. A
lock file records the exact version
of every installed package, direct and transitive, so that every
install of the project gets the same tree. npm writes it as
package-lock.json; for each package it stores the exact
version, where it was downloaded from and an
integrity hash that npm checks on every later download.
A fragment:
{
"name": "meter-report",
"lockfileVersion": 3,
"packages": {
"node_modules/date-kit": {
"version": "3.5.4",
"resolved": "https://registry.npmjs.org/date-kit/-/date-kit-3.5.4.tgz",
"integrity": "sha512-…"
}
}
}
The rules that follow from this:
-
Commit
package-lock.jsonto Git; never commitnode_modules(add it to.gitignore). -
For your own development,
npm installinstalls from the lock file and updates it when you add or change dependencies. -
In CI pipelines and on servers, use
npm ci. It requires the lock file, deletes any existingnode_modulesfirst, installs exactly what the lock file says, and stops with an error instead of changing anything whenpackage.jsonand the lock file disagree.
npm ci
npx: running package commands
npx is a command that comes with npm and
runs the command-line program of a package: the copy in the
project's node_modules if there is one, otherwise a
copy it downloads to a cache, after asking you to confirm. It is
handy for tools you use once or want to try without installing them
globally:
npx eslint src/ # run the project's own ESLint
npx prettier --check . # run Prettier, downloading it if needed
Supply-chain risks
Every package you install, and every package it pulls in, is code written by people you do not know that runs with your permissions: on your laptop during the install, on your server, or in your users' browsers. That makes the npm registry an attractive target for a supply chain attack: instead of attacking you, an attacker compromises a package you trust. Real incidents:
- ua-parser-js (October 2021): the maintainer's account was hijacked, and for about four hours versions 0.7.29, 0.8.0 and 1.0.0 of this widely used package installed a cryptocurrency miner and a password stealer. CISA (the US Cybersecurity and Infrastructure Security Agency) issued an alert.
- chalk, debug and others (September 2025): according to security researchers, a maintainer was phished through a fake "update your 2FA" email from a look-alike domain, and malicious versions of 18 very popular packages were published. Their code tried to redirect cryptocurrency payments in the browser; they were removed the same day.
- Shai-Hulud (from September 2025): a self-replicating worm that spread through stolen maintainer accounts. Its install scripts stole secrets such as tokens and keys from the machines that installed the packages, and used stolen npm credentials to publish infected versions of further packages. GitHub removed more than 500 packages in the first wave; a second wave followed in November 2025.
Many of these attacks ran through install scripts:
commands in a package's
package.json (preinstall,
install, postinstall) that npm runs during
npm install. Since npm 12 (July 2026), npm no longer
runs these scripts for dependencies unless you approve them per
package with npm approve-scripts. Node.js itself still
bundled npm 11 as of October 2026, so check which version you have
with npm --version; with older versions,
npm install --ignore-scripts gives similar protection.
Habits that reduce the risk:
- Add a dependency only when it saves real work. A ten-line helper is often safer to write yourself.
- Check the exact name, the publisher, recent activity and the number of maintainers before installing a package.
-
Commit the lock file, use
npm ciin pipelines, and review changes topackage-lock.jsonin pull requests. -
Scan for known vulnerabilities and update regularly, with
npm audit(see JavaScript Security Basics) or a service such as Dependabot. - If you publish packages yourself, protect the account with two-factor authentication and publish from CI with npm's trusted publishing instead of long-lived tokens.