The Module System (CommonJS vs ESM)
Why It Matters
Modules control how code is split, reused, loaded, tested, and published. In Node.js you will encounter two module systems: CommonJS and ES modules. Many bugs in Node.js projects come from mixing them without understanding the rules.
Modern Node.js supports ESM well, and new examples should usually prefer import and export. CommonJS still matters because a large amount of older Node.js code and many packages use require() and module.exports.
Core Concepts
ES modules
ES modules are the JavaScript standard module system.
// math.js
export function add(a, b) {
return a + b;
}
export const meaning = 42;// app.js
import { add, meaning } from './math.js';
console.log(add(2, 3));
console.log(meaning);Use ESM when:
- Your project has
"type": "module"inpackage.json. - You use
.mjsfiles. - You are writing modern Node.js examples or new application code.
CommonJS
CommonJS is Node.js's original module system.
// math.cjs
function add(a, b) {
return a + b;
}
module.exports = { add };// app.cjs
const { add } = require('./math.cjs');
console.log(add(2, 3));Use CommonJS when:
- Maintaining older projects.
- Working in a codebase that has not migrated to ESM.
- Using tools that still expect CommonJS configuration files.
How Node.js decides module type
Node.js uses extension and package metadata:
.mjsis ESM..cjsis CommonJS..jsfollows the nearestpackage.json"type"field.
{
"type": "module"
}With this setting, .js files are ESM. Without it, .js files are commonly treated as CommonJS.
Syntax and Examples
Default and named exports
// logger.js
export default function log(message) {
console.log(`[app] ${message}`);
}
export function warn(message) {
console.warn(`[app] ${message}`);
}import log, { warn } from './logger.js';
log('started');
warn('low disk space');Named exports are often easier to refactor because import names stay explicit. Default exports can be convenient for a module with one main purpose.
Importing built-in modules
Prefer node: specifiers:
import { readFile } from 'node:fs/promises';
import path from 'node:path';
const filePath = path.join(process.cwd(), 'package.json');
const packageJson = await readFile(filePath, 'utf8');
console.log(packageJson);Dynamic import
import() loads a module asynchronously. It works in both ESM and CommonJS.
const moduleName = './feature.js';
const feature = await import(moduleName);
feature.run();Use dynamic import for optional features, conditional loading, or expensive modules that are not always needed.
CommonJS and ESM Interop
ESM can usually import CommonJS default-style:
import legacyPackage from 'legacy-package';CommonJS cannot directly use top-level import, but it can use dynamic import:
async function main() {
const { default: chalk } = await import('chalk');
console.log(chalk.green('ok'));
}
main();Interop rules can be surprising. For application code, prefer choosing one module system per project.
Common Mistakes
- Forgetting file extensions in relative ESM imports. Use
./math.js, not./math. - Mixing
require()into ESM files. - Expecting
__dirnameand__filenamein ESM. Useimport.meta.urlwhen needed. - Changing
"type"inpackage.jsonwithout checking config files and scripts. - Publishing packages without declaring exports clearly.
Practical Challenge
Create an ESM project with:
package.jsoncontaining"type": "module"calculator.jsexportingadd,subtract, andmultiplyindex.jsimporting those functions and reading two numbers fromprocess.argv
Run:
node index.js 6 7Then rewrite the same example as CommonJS using .cjs files.
Recap
ESM is the modern JavaScript module system and should be your default for new Node.js learning. CommonJS remains important for older code and tooling. Know how Node.js chooses module type, use explicit relative file extensions in ESM, and prefer node: imports for built-in modules.