CommonJS vs ES Modules
Reviewed & published by Brayan K
CommonJS and ES Modules are Node's two systems for splitting code across files: CommonJS uses require and module.exports, while ES Modules use the standard import and export keywords.
Part of the free Node.js course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
By the end of this lesson you'll know how to export and import with both systems, how to pick between .cjs, .mjs, and "type":"module", the difference between default and named exports, how to use top-level await in ESM, and how to dodge the classic interop pitfalls.
What You'll Learn in This Lesson
- How module.exports + require work in CommonJS
- How export + import work in ES Modules (ESM)
- The difference between default and named exports
- How .cjs, .mjs, and "type":"module" decide the module system
- Why top-level await only works in ES Modules
- How to recreate __dirname in ESM with import.meta.url
1️⃣ CommonJS: require & module.exports
CommonJS is the module system Node shipped with for years, so you'll see it everywhere. A file publishes what it wants to share by assigning to module.exports, and another file pulls that in with require('./file'). The call is synchronous — require returns the value right away.
A handy trick: because require returns a plain object, you can destructure the exact pieces you want straight out of it, like const { add } = require('./math.cjs').
// === math.cjs === (CommonJS — the original Node module system)
// You attach things you want to share onto module.exports.
function add(a, b) { return a + b; }
const PI = 3.14159;
module.exports = { add, PI };
// === app.cjs ===
// require() pulls in whatever the other file exported.
const math = require('./math.cjs');
console.log(math.add(2, 3)); // -> 5
console.log(math.PI); // -> 3.14159
// You can destructure straight out of require:
const { add } = require('./math.cjs');
console.log(add(10, 20)); // -> 302️⃣ ES Modules: import & export
ES Modules (ESM) are the official JavaScript standard, the same syntax you use in the browser. You mark shared values with export and load them with import. There are two kinds: named exports (any number per file, imported in { }) and one default export per file (imported with no braces, and you may rename it freely).
ESM unlocks a feature CommonJS never had: top-level await. Because ES Modules load asynchronously, you can write await directly at the top of a file without wrapping it in an async function.
// === math.mjs === (ES Modules — the modern standard)
// "export" marks NAMED exports; "export default" marks THE default one.
export function add(a, b) { return a + b; }
export const PI = 3.14159;
export default function greet(name) { return `Hi, ${name}!`; }
// === app.mjs ===
// The default import has no braces; named imports go in { }.
import greet, { add, PI } from './math.mjs';
console.log(add(2, 3)); // -> 5
console.log(PI); // -> 3.14159
console.log(greet('Ada')); // -> Hi, Ada!
// Bonus: ES Modules allow TOP-LEVEL await — no async wrapper needed.
const value = await Promise.resolve('loaded');
console.log(value); // -> loaded3️⃣ __dirname & __filename in ESM
A common surprise when moving to ES Modules: __dirname and __filename simply don't exist. They were CommonJS globals. In ESM you reconstruct them from import.meta.url using fileURLToPath and dirname.
On recent Node versions there's an even shorter path: import.meta.dirname and import.meta.filename give you the same strings with no imports at all.
// In CommonJS, __dirname and __filename are always available.
// In ES Modules they DON'T exist — you rebuild them from import.meta.url.
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Do NOT assert on the file's name — that depends on what you saved it as,
// and this snippet claimed "paths.mjs" while printing false for anyone who
// chose any other name. Assert the things that are true whatever you call it:
console.log(typeof __filename); // -> string
console.log(typeof __dirname); // -> string
console.log(__filename.startsWith(__dirname)); // -> true: the file is inside its own folder
// On modern Node you can also use these directly, no imports required:
console.log(typeof import.meta.dirname); // -> stringYour turn. Fill in the two blanks marked ___ to import the default export and the named export, then run it.
// === greet.mjs ===
export const version = '1.0';
export default function hello(name) { return `Hello, ${name}!`; }
// === turn.mjs === 🎯 YOUR TURN — fill in each ___ then run it.
// 1) Import the DEFAULT export (no braces) and call it 'hello'
// AND the NAMED export 'version' (in braces).
import ___, { ___ } from './greet.mjs'; // 👉 hello , version
console.log(hello('Sam')); // -> Hello, Sam!
console.log(`v${version}`); // -> v1.0Mini-Challenge: CommonJS-to-ESM Interop
No blanks — just a brief and an outline. Importing a CommonJS file from an ES Module is the single most common interop case in real projects, so it's worth practising. Write it yourself, run it, and check your output against the comments.
// 🎯 MINI-CHALLENGE: import a CommonJS file from an ES Module.
// This is the most common interop case in real projects.
//
// === config.cjs === (CommonJS)
// module.exports = { appName: 'Notes', maxItems: 50 };
//
// === main.mjs === (ES Module)
// When you import a CommonJS module, its module.exports becomes
// the DEFAULT import. So:
//
// 1. import the whole module.exports object as a default import
// 2. log config.appName and config.maxItems
//
// ✅ Expected output:
// Notes
// 50
// your code hereCommon Errors (and the fix)
- ❌ "Cannot use import statement outside a module" — Node is treating your file as CommonJS. ✅ Rename it to .mjs, or add "type": "module" to package.json.
- ❌ "require is not defined in ES module scope" — You used require in an ESM file. ✅ Switch to import, or for an occasional case use dynamic await import('...').
- ❌ "__dirname is not defined" — That global doesn't exist in ESM. ✅ Rebuild it from import.meta.url, or use import.meta.dirname.
- ❌ "The requested module does not provide an export named 'X'" — You imported a name in { } that isn't a named export. ✅ Check spelling, or import the default (no braces) if that's what the file exports.
- ❌ "Cannot find module './math'" — In ESM the file extension is required. ✅ Write the full path, e.g. ./math.mjs.
Pro Tips
- 💡 Pick one system per project: set "type": "module" and write ESM everywhere. It's the standard and avoids constant mental switching.
- 💡 Prefer named exports for most things — they're explicit and tooling can autocomplete them. Reserve the default export for a module's single "main" thing.
- 💡 Always include the file extension in ESM import paths (./util.mjs), unlike CommonJS where it was optional.
📋 Quick Reference — CommonJS vs ES Modules
| Task | CommonJS | ES Modules |
|---|---|---|
| Export many | module.exports = { add } | export { add } |
| Export default | module.exports = fn | export default fn |
| Import named | const { add } = require() | import { add } from |
| Import default | const fn = require() | import fn from |
| File extension | .cjs | .mjs |
| Top-level await | no | yes |
🎉 Lesson Complete!
- ✅ CommonJS uses module.exports + require; ES Modules use export + import
- ✅ Named exports go in { }; the single default export imports with no braces
- ✅ .mjs is always ESM, .cjs is always CommonJS, and "type":"module" sets the default for .js
- ✅ Only ES Modules support top-level await
- ✅ __dirname doesn't exist in ESM — rebuild it from import.meta.url
Practice quiz
Which syntax does CommonJS use to share and load code?
- export and import
- export default and await import
- module.exports and require()
- include and provide
Answer: module.exports and require(). CommonJS publishes values on module.exports and loads them with require(), Node's original module system.
Which keywords do ES Modules use?
- import and export
- require and module.exports
- include and define
- load and share
Answer: import and export. ES Modules use the standard import and export keywords, the same syntax used in browsers.
Is require() synchronous or asynchronous?
- Asynchronous, returning a promise
- It depends on the file size
- It always blocks for one second
- Synchronous, returning the value right away
Answer: Synchronous, returning the value right away. require() runs synchronously and returns module.exports immediately.
How do you import a NAMED export in ESM?
- Without braces: import add from './math.mjs'
- With braces: import { add } from './math.mjs'
- With require()
- With import.meta
Answer: With braces: import { add } from './math.mjs'. Named exports are imported inside braces; the default export is imported with no braces.
How many default exports can a single ES Module have?
- One
- Unlimited
- Zero, defaults are not allowed
- Exactly two
Answer: One. A file can have many named exports but only one default export.
Which feature works in ES Modules but not CommonJS?
- require()
- module.exports
- Top-level await
- synchronous loading
Answer: Top-level await. Because ES Modules load asynchronously, you can use await at the top level without an async wrapper.
What does a .mjs file extension always mean to Node?
- The file is treated as CommonJS
- The file is treated as an ES Module
- The file is minified
- Node ignores the extension
Answer: The file is treated as an ES Module. A .mjs file is always ESM, a .cjs file is always CommonJS, regardless of package.json.
How do you make plain .js files ES Modules by default?
- Rename them to .cjs
- Use require() everywhere
- Add a shebang line
- Add "type": "module" to package.json
Answer: Add "type": "module" to package.json. For .js files Node checks package.json's "type" field; "module" makes them ESM, though the extension always wins.
Why is __dirname undefined in an ES Module?
- Node has a bug
- It is a CommonJS-only global; rebuild it from import.meta.url
- It must be imported from node:path
- It only works on Windows
Answer: It is a CommonJS-only global; rebuild it from import.meta.url. __dirname and __filename do not exist in ESM; recreate them via fileURLToPath(import.meta.url).
When you default-import a CommonJS module from ESM, what do you get?
- Only its named exports
- An empty object
- Its module.exports object
- A promise
Answer: Its module.exports object. Importing a CommonJS module gives you its module.exports as the default import.
Continue this course
- Previous: Testing (node:test)
- Next: Buffers & Binary Data — Work with raw bytes using Buffer — from, alloc, encodings, and concat
- Quick reference: Node.js cheat sheet
Frequently asked questions
What is the difference between require and import?
require is the CommonJS way to load modules: it runs synchronously, returns module.exports, and is what Node used by default for years. import is the ES Modules (ESM) standard, the same syntax browsers use: it is statically analysed, supports named and default exports, and enables features like top-level await. Use import/export in new projects; you will still meet require in older code and many npm packages.
How do I tell Node to treat my file as an ES Module?
There are three ways. Use a .mjs extension and Node always treats it as ESM. Use a .cjs extension and Node always treats it as CommonJS. Or, for plain .js files, add "type": "module" to your package.json to make every .js file ESM by default (or "type": "commonjs" to keep them CommonJS). The extension always wins over the package.json setting.
Why is __dirname undefined in my ES Module?
__dirname and __filename are CommonJS-only globals; they do not exist in ES Modules. In an ESM file, rebuild them from import.meta.url: const __filename = fileURLToPath(import.meta.url) and const __dirname = dirname(__filename). On recent Node versions you can also use import.meta.dirname and import.meta.filename directly without any imports.
Can I use require inside an ES Module, or import inside CommonJS?
You cannot use the import statement inside a CommonJS file, but you can load an ES Module from CommonJS with the dynamic await import('./mod.mjs') form. Going the other way, a default import of a CommonJS module gives you its module.exports object. The cleanest rule of thumb is to pick one system per project and only reach for dynamic import() when you must mix them.