Node.js Modules: CommonJS vs ES Modules
A practical comparison of CommonJS and ES Modules in Node — require vs import, module.exports vs export, the type field in package.json, and the interop traps to avoid.
What you'll learn
- ✓How CommonJS works — require, module.exports, and exports
- ✓How ES Modules work — import and export
- ✓What "type": "module" does in package.json
- ✓When to use .cjs and .mjs file extensions
- ✓The interop rules between the two systems and where they break
- ✓Which module system to pick for new projects
Prerequisites
- •Node installed locally — see Install Node.js and Run Your First Script
- •Comfort with basic JavaScript functions — see JavaScript Functions
Every non-trivial Node program is split across multiple files. The way those files share code is called a module system, and Node has two of them: the original CommonJS (CJS) and the newer ES Modules (ESM). They look similar, behave differently, and meet at awkward edges.
This post explains both, shows the gotchas, and gives you a clear default for new code.
Why two systems exist
When Node launched in 2009, JavaScript had no standard module system. Node invented CommonJS — require and module.exports — and it served the ecosystem for a decade.
In 2015, the language itself standardised ES Modules — import and export. The browser adopted them; bundlers adopted them; eventually Node added support in 2019. Today both work, but new projects increasingly default to ESM.
CommonJS in one example
A CJS module exports values by assigning to module.exports. Another file imports them with require.
// math.js
function add(a, b) {
return a + b;
}
function multiply(a, b) {
return a * b;
}
module.exports = { add, multiply };
// app.js
const { add, multiply } = require('./math');
console.log(add(2, 3)); // 5
console.log(multiply(4, 5)); // 20
Run it:
node app.js
# output:
# 5
# 20
A few things to notice:
requiretakes a path relative to the current file (./math), and the.jsextension is optional.module.exportsis whatever this file makes available — usually an object, but it can be anything (a function, a class, a string).- The shorthand
exports.add = ...works, but assigning a whole new value toexportsdoes not. Usemodule.exportswhen in doubt.
CJS is synchronous. require reads the file, runs it top to bottom, caches the result, and returns it — all before the next line of your code executes.
ES Modules in one example
The same module in ESM:
// math.js
export function add(a, b) {
return a + b;
}
export function multiply(a, b) {
return a * b;
}
// app.js
import { add, multiply } from './math.js';
console.log(add(2, 3)); // 5
console.log(multiply(4, 5)); // 20
Two differences jump out:
exportandimportreplacemodule.exportsandrequire.- The file extension
.jsis required in the import path. ESM does not auto-resolve extensions the way CJS does.
To run this, Node needs to know your project uses ESM. Add "type": "module" to your package.json:
{
"type": "module"
}
Now node app.js works. Without the type field, Node treats .js files as CommonJS by default.
The type field in package.json
This single field decides how Node interprets your .js files:
"type": "commonjs"(or omitted) —.jsfiles are CommonJS"type": "module"—.jsfiles are ES Modules
The type field applies to all .js files inside the package, including subdirectories, until another package.json overrides it.
File extensions: .cjs and .mjs
You can override the package-level choice on a per-file basis with extensions:
.cjs— always CommonJS, regardless of"type".mjs— always ES Modules, regardless of"type".js— follows the"type"field
This is useful when you need to mix systems, usually because a tool requires CJS while the rest of your code is ESM.
my-project/
├── package.json # "type": "module"
├── server.js # ESM
├── utils.js # ESM
└── legacy-config.cjs # CommonJS
Default exports
Both systems support a single “default” export, but they spell it differently.
CommonJS:
// logger.js
module.exports = function log(message) {
console.log(`[LOG] ${message}`);
};
// app.js
const log = require('./logger');
log('hello'); // [LOG] hello
ES Modules:
// logger.js
export default function log(message) {
console.log(`[LOG] ${message}`);
}
// app.js
import log from './logger.js';
log('hello'); // [LOG] hello
A module can have one default export and any number of named exports:
// utils.js
export default function main() { /* ... */ }
export function helper() { /* ... */ }
export const VERSION = '1.0.0';
import main, { helper, VERSION } from './utils.js';
Try it yourself. Create a folder with package.json containing { "type": "module" }. Write greet.js that exports a greet(name) function, then index.js that imports and calls it. Run node index.js. Then rename greet.js to greet.cjs and watch Node complain — fix it by converting the export to module.exports.
Built-in modules
Node ships with modules like fs, path, and http. Both systems can import them:
// CommonJS
const fs = require('fs');
const { readFile } = require('fs/promises');
// ESM
import fs from 'node:fs';
import { readFile } from 'node:fs/promises';
The node: prefix is the modern, explicit form. It tells the reader (and the runtime) that this is a built-in, not a package from npm. Prefer it in new code.
Top-level await
ES Modules support await at the top level of a file. CommonJS does not.
// fetch-data.mjs
const res = await fetch('https://api.example.com/data');
const data = await res.json();
console.log(data);
This alone is a strong reason to choose ESM for new projects — it makes startup scripts and configuration files dramatically cleaner.
Interop: importing CJS from ESM
You can import a CommonJS module from ESM, but with rules:
// CJS file: math.cjs
module.exports = { add: (a, b) => a + b };
// ESM file
import math from './math.cjs';
console.log(math.add(2, 3)); // 5
The whole module.exports object comes in as the default import. Named destructuring sometimes works (Node tries to detect the shape) but is not guaranteed:
// May work, may not — depends on the CJS module's shape
import { add } from './math.cjs';
The safe pattern is to import the default, then destructure:
import math from './math.cjs';
const { add } = math;
Interop: importing ESM from CJS
The other direction is harder. CommonJS require cannot synchronously load an ES Module — ESM is async. You must use dynamic import:
// CJS file
async function main() {
const { add } = await import('./math.mjs');
console.log(add(2, 3));
}
main();
This catches people out when a popular library goes “ESM-only” — suddenly older CJS codebases cannot require it any more. Dynamic import() is the workaround.
__dirname and __filename
CommonJS gives you two magic variables for the current file’s path:
console.log(__dirname); // /Users/me/project
console.log(__filename); // /Users/me/project/app.js
ESM does not. Use import.meta.url and the node:url module:
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
Recent Node versions add import.meta.dirname and import.meta.filename directly, which is much nicer:
console.log(import.meta.dirname); // /Users/me/project
console.log(import.meta.filename); // /Users/me/project/app.js
We cover this in detail in Node.js fs and path.
JSON imports
CommonJS lets you require a JSON file directly:
const config = require('./config.json');
ESM needs an import attribute:
import config from './config.json' with { type: 'json' };
The syntax has evolved (older Node used assert instead of with). Check node --version if it complains.
Try it yourself. Create an ESM project. Import node:fs/promises, write a top-level await readFile('./package.json', 'utf-8'), parse the JSON, and log the name field. Notice that you never wrap any of this in async function main() — top-level await makes the whole file behave like an async body.
Which should you choose
For new code: use ES Modules. The reasons:
- It is the language standard. The same syntax works in browsers, Deno, Bun, and bundlers.
- Top-level await is genuinely useful.
- New popular libraries are increasingly ESM-only.
- The tooling story is mature now — what was painful in 2020 is fine in 2026.
Stay with CommonJS only if:
- You are working in an existing CJS codebase and a migration is not worth the disruption
- You depend on a specific tool that does not yet support ESM
- You need the synchronous
requiresemantics for an unusual reason (rare)
Recap
You now know:
- CommonJS uses
requireandmodule.exportsand is synchronous - ES Modules use
import/exportand are the language standard "type": "module"inpackage.jsonswitches.jsfiles to ESM.cjsand.mjsextensions force a specific system per file- ESM unlocks top-level await and uses
import.meta.urlfor paths - Importing CJS from ESM is easy; importing ESM from CJS needs dynamic
import() - Default to ESM for new projects
Next steps
Modules let you split a single project across files. To use other people’s code — frameworks, utilities, database drivers — you need npm.
Next: npm and package.json Explained
Questions or feedback? Email codeloomdevv@gmail.com.
Related articles
- Node.js Install Node.js and Run Your First Script
A practical guide to installing Node.js with nvm on macOS, Linux, and Windows — checking versions, running .js files, and using the REPL for quick experiments.
- Node.js Node.js fs and path: Reading and Writing Files
A practical guide to file I/O in Node — fs/promises vs callback vs sync, path.join for safe paths, and how to find the current file with __dirname or import.meta.url.
- Node.js npm and package.json Explained
A practical tour of npm and package.json — npm init, dependencies vs devDependencies, scripts, the lockfile, semver basics, and the difference between npm install and npx.
- Node.js What Is Node.js? JavaScript Outside the Browser
A clear introduction to Node.js — the V8 engine, the event loop, non-blocking I/O, the npm ecosystem, and when Node is the right runtime for your project.