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 .mjs file is always an ES module; a .cjs file is always CommonJS.
  • A .js file is an ES module when the nearest package.json contains "type": "module", and CommonJS when it contains "type": "commonjs".
  • A .js file without a "type" is treated as CommonJS, but recent Node.js releases look at the code first: if it contains import or export, they run it as an ES module, and print a warning about the missing type when a package.json exists. 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:

Dependency tree: the project meter-report depends directly on report-lib ^2.1.0 and date-kit ~3.5.0. report-lib in turn needs csv-core ^1.0.0 and date-kit ^3.0.0; one copy of date-kit 3.5.x satisfies both ranges. meter-report report-lib ^2.1.0 date-kit ~3.5.0 direct csv-core ^1.0.0 needs date-kit ^3.0.0 3.5.x fits both: one copy transitive
Your project names its direct dependencies; npm resolves the whole tree and shares a copy where the ranges overlap.

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"
  }
}
  • dependencies are needed to run the program; devDependencies only to develop it (linters, test tools). npm install --omit=dev skips the latter on a production server.
  • "private": true stops the project from being published to the registry by accident.
  • engines documents 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.json to Git; never commit node_modules (add it to .gitignore).
  • For your own development, npm install installs 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 existing node_modules first, installs exactly what the lock file says, and stops with an error instead of changing anything when package.json and 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 ci in pipelines, and review changes to package-lock.json in 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.