Skip to content

Modules & npm

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.

CommonJSESM
Syntaxrequire() / module.exportsimport / export
File extension.js (default) or .cjs.mjs, or .js when "type": "module"
"type" in package.json"commonjs" (or absent)"module"
LoadingSynchronousAsynchronous (static analysis)
Top-level awaitNot supportedSupported
LessonTopic
Overview (this page)CJS vs ESM comparison and module system basics
package.json Deep DiveKey fields, semver ranges, and npm init
Module ResolutionHow Node finds files, the exports map, and bare specifiers
Managing DependenciesInstalling, updating, and auditing packages with npm
Publishing PackagesPreparing and publishing a package to the npm registry

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

Node.js
Output (verified on Node 24)
path.sep: /
Joined path: src/content/index.mdx

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

Node.js
Output (verified on Node 24)
probe.js under { "type": "commonjs" }:
CJS (module global present)
identical probe.js under { "type": "module" }:
ESM (no module global)

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

ChoiceBenefitCost
Explicit "type" in every packageFast resolution (no file sniffing), unambiguous to a readerOne 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 correctlySlower 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 subfolderLets one legacy CommonJS folder live inside an ESM project without renaming every file in itEasy 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.jsonExplicit per file, no scope boundaries to reason aboutRenaming churn on existing files; some older tooling only recognises .js
  • “My project’s root "type": "module" covers everything I run, including my dependencies.” No — every installed package has its own package.json and its own "type" boundary. Your "type" field only governs files inside your own package.
  • “Without a "type" field, .js is CommonJS forever.” Not since detect-module landed as the default: Node 24 can parse an ambiguous .js file’s syntax and treat it as ESM automatically if it uses import/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 different package.json’s scope, the exact same file content can be parsed as a different module system after the move.

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')).

Which syntax does CommonJS use to load another module?
What file extension always forces a file to be treated as an ES module regardless of package.json?
Which package.json field switches the entire package to ESM so that .js files are treated as ES modules?
What happens when this file runs?
// package.json (nearest to this file): { "type": "module" }
const data = require('./data.json');
console.log(data);
Folder A has a package.json with "type": "commonjs"; folder B has one with "type": "module". Both contain a probe.js file with the byte-identical source console.log(typeof module);. What do the two files print?