Modules & npm
Two module systems, one ecosystem
Section titled “Two module systems, one ecosystem”Node.js ships with two module systems. CommonJS (CJS) is the original system and uses require() to load modules synchronously. ECMAScript Modules (ESM) is the modern standard, shared with browsers, and uses import / export statements.
Both systems are fully supported in current Node.js versions. Which one a file uses depends on the file extension and the "type" field in the nearest package.json.
CJS vs ESM at a glance
Section titled “CJS vs ESM at a glance”| CommonJS | ESM | |
|---|---|---|
| Syntax | require() / module.exports | import / export |
| File extension | .js (default) or .cjs | .mjs, or .js when "type": "module" |
"type" in package.json | "commonjs" (or absent) | "module" |
| Loading | Synchronous | Asynchronous (static analysis) |
Top-level await | Not supported | Supported |
What this module covers
Section titled “What this module covers”| Lesson | Topic |
|---|---|
| Overview (this page) | CJS vs ESM comparison and module system basics |
| package.json Deep Dive | Key fields, semver ranges, and npm init |
| Module Resolution | How Node finds files, the exports map, and bare specifiers |
| Managing Dependencies | Installing, updating, and auditing packages with npm |
| Publishing Packages | Preparing and publishing a package to the npm registry |
ESM in action
Section titled “ESM in action”The snippet below uses ESM import to load the built-in path module and print the platform path separator — the character Node uses between directory components (/ on Unix, \ on Windows):
Output (verified on Node 24)
path.sep: /
Joined path: src/content/index.mdxPackage scope: which package.json controls a file
Section titled “Package scope: which package.json controls a file”The "type" field is not a project-wide switch — it is scoped to the nearest package.json above the file being loaded. Node walks up from the file’s own directory until it finds one. A subfolder with its own package.json creates a new boundary: everything below it obeys the new file’s "type", not the one further up the tree. A dependency in node_modules is a separate boundary too — your own "type" never reaches into a package you installed.
The demo below writes the exact same source text into two folders that differ only in their package.json’s "type", then loads both. The source contains no import/export/require syntax at all — just typeof module, which is only defined inside a CommonJS module wrapper — so any difference in output comes entirely from the governing package.json, not from the code itself.
Output (verified on Node 24)
probe.js under { "type": "commonjs" }:
CJS (module global present)
identical probe.js under { "type": "module" }:
ESM (no module global)Mental Model
Section titled “Mental Model”A file’s module system is not a property of the file — it is a property of the folder it lives in. Node never asks “what does this file look like”; it asks “what does the nearest package.json above this file say”. A monorepo with ten packages can have ten different answers, side by side, without any file renaming, as long as each package declares its own "type".
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
Explicit "type" in every package | Fast resolution (no file sniffing), unambiguous to a reader | One more field to keep correct as a package evolves |
Rely on detect-module (no "type" field) | Zero-config; Node 24 can parse ambiguous .js syntax and guess correctly | Slower to resolve; a human skimming the tree cannot tell the module system at a glance |
Nest a nested package.json to flip "type" for one subfolder | Lets one legacy CommonJS folder live inside an ESM project without renaming every file in it | Easy to miss — “why is this one folder different from the rest” is a common source of confusion during review |
Use .mjs/.cjs extensions instead of a nested package.json | Explicit per file, no scope boundaries to reason about | Renaming churn on existing files; some older tooling only recognises .js |
Common Misconceptions
Section titled “Common Misconceptions”- “My project’s root
"type": "module"covers everything I run, including my dependencies.” No — every installed package has its ownpackage.jsonand its own"type"boundary. Your"type"field only governs files inside your own package. - “Without a
"type"field,.jsis CommonJS forever.” Not sincedetect-modulelanded as the default: Node 24 can parse an ambiguous.jsfile’s syntax and treat it as ESM automatically if it usesimport/export. An explicit"type"is still the faster, clearer choice — but the absence of one is no longer a hard guarantee of CommonJS. - “Moving a file to a different folder never changes its module system, as long as the extension stays
.js.” It can — if the destination folder falls under a differentpackage.json’s scope, the exact same file content can be parsed as a different module system after the move.
Reading AI-generated code
Section titled “Reading AI-generated code”Training data is full of pre-2020 Node examples where every .js file is CommonJS by default. A model that has not internalised a project’s "type": "module" will often reach for require() out of habit, even inside a codebase that has explicitly opted into ESM everywhere.
🐛 Spot the bug
// package.json (nearest to this file): { "type": "module" }const fs = require('node:fs');const data = require('./data.json');
console.log(data);Show the answer
The nearest package.json says "type": "module", so this .js file is an ES module — and require is not a global inside ES modules. This throws ReferenceError: require is not defined in ES module scope, you can use import instead on the very first line, before data.json is ever touched. The fix: use import fs from 'node:fs'; and, for the JSON file, either import data from './data.json' with { type: 'json' }; (Node 22.10+ import attributes) or JSON.parse(fs.readFileSync('./data.json', 'utf8')).