Node.js Modules: ESM vs CommonJS Explained
Node.js supports two module systems: CommonJS and ESM (ECMAScript Modules). This article explains how they differ, how they affect syntax and loading behavior, and how to choose the right one for your project.
Quick answer: CommonJS uses require() and module.exports, while ESM uses import and export. In modern Node.js, ESM is the standard JavaScript module syntax, but CommonJS is still widely used and supported.
Difficulty: Beginner to Intermediate
You'll understand this better if you know: basic JavaScript syntax, how files can export values, and the difference between named and default values.
1. What Are ESM and CommonJS?
Modules let you split code into files and reuse functionality without putting everything in one script. In Node.js, the two main module systems are CommonJS and ESM.
- CommonJS is the older Node.js module system.
- ESM is the official JavaScript module standard.
- Both let you export code from one file and use it in another.
- They are not identical: they use different syntax and follow different loading rules.
For example, a utility file can export a function, and another file can import it. The syntax you use depends on whether the file is treated as CommonJS or ESM.
2. Why This Matters
Choosing the wrong module system can cause confusing runtime errors, failed imports, or package compatibility problems. Many Node.js projects still use CommonJS, but modern libraries increasingly publish ESM or dual packages.
Understanding both systems helps you:
- read older Node.js code confidently,
- use newer packages that ship as ESM-only,
- avoid import and export errors,
- configure your project correctly with package.json.
3. Basic Syntax or Core Idea
CommonJS export and import
CommonJS uses module.exports to export values and require() to load them.
function sum(a, b) {
return a + b;
}
module.exports = sum;Another file loads it with require():
const sum = require("./sum");
console.log(sum(2, 3));ESM export and import
ESM uses export and import.
export function sum(a, b) {
return a + b;
}And in another file:
import { sum } from "./sum.js";
console.log(sum(2, 3));The main idea is simple: both systems share the same goal, but they use different file loading rules and different syntax.
4. Step-by-Step Examples
Example 1: Exporting a single function in CommonJS
This pattern is common in older Node.js code and many existing packages.
function formatName(first, last) {
return `${first} ${last}`;
}
module.exports = formatName;This file exports one function as the module value itself, so the importing file gets that function directly.
Example 2: Importing a CommonJS module
Use require() to load the exported function.
const formatName = require("./format-name");
const displayName = formatName("Ada", "Lovelace");
console.log(displayName);Here, Node.js resolves the file, evaluates it, and returns the value assigned to module.exports.
Example 3: Exporting named values in ESM
ESM supports named exports, which makes it easy to share multiple values from one file.
export const pi = 3.14159;
export function circleArea(radius) {
return pi * radius * radius;
}Both pi and circleArea are available to import by name.
Example 4: Importing named exports in ESM
Use matching names inside curly braces.
import { circleArea, pi } from "./math.js";
console.log(pi);
console.log(circleArea(2));This shows the most common ESM style: explicit named imports that match the exported names.
Example 5: Default export in ESM
ESM can also export a single default value.
export default function greet(name) {
return `Hello, ${name}!`;
}And import it without curly braces:
import greet from "./greet.js";
console.log(greet("Maya"));This is useful when a file has one primary export.
5. Practical Use Cases
- Use CommonJS when maintaining older Node.js applications that already rely on require().
- Use ESM for new Node.js projects when you want to align with the JavaScript standard.
- Use CommonJS when you depend on packages that are still CommonJS-only.
- Use ESM when you want native import syntax and better alignment with browser JavaScript.
- Use dual-package support when you publish a library that must work in both ecosystems.
In practice, many teams use CommonJS for existing backend services and ESM for new codebases or shared libraries.
6. Common Mistakes
Mistake 1: Using import in a CommonJS file without enabling ESM
Beginners often write ESM syntax in a file that Node.js still treats as CommonJS.
Problem: Node.js will not parse import in a CommonJS file, so you may see an error like “Cannot use import statement outside a module”.
import { readFile } from "node:fs/promises";
console.log("Loaded");Fix: Either switch the project to ESM or use require() in CommonJS.
const { readFile } = require("node:fs/promises");
console.log("Loaded");The corrected version works because CommonJS files must use CommonJS loading rules unless the package is configured for ESM.
Mistake 2: Using require() in an ESM file
When a file is treated as ESM, require() is not available by default.
Problem: In ESM, Node.js can report “require is not defined in ES module scope”.
const path = require("node:path");
console.log(path.basename("/tmp/file.txt"));Fix: Use import in ESM files.
import path from "node:path";
console.log(path.basename("/tmp/file.txt"));This works because ESM expects standard module imports instead of CommonJS loading functions.
Mistake 3: Forgetting the file extension in ESM relative imports
Node.js ESM is stricter than CommonJS about relative paths.
Problem: In many ESM setups, omitting the extension in a relative import causes module resolution failure.
import { sum } from "./math";Fix: Include the full file name, including .js for local files.
import { sum } from "./math.js";This works because ESM follows explicit file resolution rules for local relative imports.
Mistake 4: Mixing default and named exports incorrectly
CommonJS and ESM both support single-value and multi-value exports, but the syntax is not interchangeable.
Problem: Importing a default export as a named import, or vice versa, leads to undefined values or syntax errors.
export default function slugify(text) {
return text.toLowerCase().replace(/\s+/g, "-");
}import { slugify } from "./slugify.js";Fix: Import default exports without curly braces.
import slugify from "./slugify.js";The fixed version works because default exports must be imported as the default binding.
7. Best Practices
Practice 1: Be consistent within a package
Mixing module systems in the same package increases confusion and makes imports harder to reason about. Pick one system for the package and use it consistently unless you have a specific interoperability need.
// Preferred: use one style consistently in the package
export function parseId(value) {
return Number(value);
}Practice 2: Use explicit file extensions in ESM
Explicit extensions make imports clearer and avoid resolution surprises in Node.js.
import { parseId } from "./parse-id.js";This helps prevent “module not found” problems caused by ambiguous relative paths.
Practice 3: Choose named exports for shared utilities
Named exports make it easier to refactor and reduce accidental renaming across files.
export function formatCurrency(amount) {
return `$${amount.toFixed(2)}`;
}With named exports, imports stay clear about what the module provides.
Practice 4: Use CommonJS only when compatibility requires it
If you are starting a new backend project, ESM is usually the better default. CommonJS is still valuable for older code and packages that depend on it, but it is no longer the best choice simply because it is familiar.
// Legacy compatibility case
const legacyLib = require("legacy-lib");This keeps your code aligned with the ecosystem you are actually targeting.
8. Limitations and Edge Cases
- CommonJS cannot use top-level import and export syntax without switching the file or package to ESM.
- ESM is stricter about file extensions in relative imports.
- Some packages are ESM-only, which means require() will fail with ERR_REQUIRE_ESM.
- Some older packages are CommonJS-only, so direct named imports may not work the way you expect.
- Interop between the systems is possible, but the returned shapes can be surprising, especially with default exports.
- Built-in Node.js modules may behave slightly differently depending on whether you import them with import or load them with require().
One common “not working” scenario is importing a local file in ESM without the extension. Another is using a package that changed to ESM-only and getting ERR_REQUIRE_ESM when you try to load it with require().
9. Practical Mini Project
Here is a small ESM-based command-line utility that splits a sentence into words and counts them. It shows a realistic Node.js workflow with reusable modules.
// file: text-utils.js
export function countWords(text) {
return text
.trim()
.split(/\s+/)
.filter(Boolean)
.length;
}
// file: app.js
import { countWords } from "./text-utils.js";
const input = "Node.js modules are useful";
const total = countWords(input);
console.log(`Word count: ${total}`);Run the app file with Node.js in an ESM-configured project, and it prints the number of words in the sample string. This demonstrates how one module can export a function and another module can reuse it cleanly.
10. Key Points
- CommonJS uses require() and module.exports.
- ESM uses import and export.
- ESM is the JavaScript standard module format, but CommonJS remains important in Node.js.
- File type and package.json configuration determine how Node.js interprets a file.
- Interop is possible, but mixing the systems requires careful syntax and import shape awareness.
11. Practice Exercise
- Create two files: one that exports a greeting function and one that uses it.
- Do it once with CommonJS and once with ESM.
- Make sure the output prints a message like Hello, Sam!.
Expected output:
Hello, Sam!
Hint: In CommonJS, export with module.exports. In ESM, export with export and import with import.
Solution:
// CommonJS version: greet.cjs
function greet(name) {
return `Hello, ${name}!`;
}
module.exports = greet;
// CommonJS version: app.cjs
const greetCommonJs = require("./greet.cjs");
console.log(greetCommonJs("Sam"));
// ESM version: greet.js
export function greet(name) {
return `Hello, ${name}!`;
}
// ESM version: app.js
import { greet } from "./greet.js";
console.log(greet("Sam"));This exercise helps you see the same idea expressed in both systems, which makes the syntax differences easier to remember.
12. Final Summary
Node.js supports both CommonJS and ESM, and each one solves the same core problem: splitting code across files and reusing it safely. CommonJS is the traditional Node.js system and still powers a large amount of existing code. ESM is the modern JavaScript standard and is increasingly the best choice for new projects.
The most important differences are syntax and loading behavior. CommonJS uses require() and module.exports; ESM uses import and export. Once you understand how Node.js decides whether a file is CommonJS or ESM, most module errors become easier to diagnose.
If you are starting a new project, learn ESM first and keep CommonJS knowledge for maintenance and interoperability. Next, try converting a small Node.js file pair from CommonJS to ESM so you can practice the syntax and see the loading differences in action.