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

CommonJS export and import

CommonJS uses module.exports to export values and require() to load them.

JavaScript
function sum(a, b) {
  return a + b;
}

module.exports = sum;

Another file loads it with require():

JavaScript
const sum = require("./sum");

console.log(sum(2, 3));

ESM export and import

ESM uses export and import.

Terminal
export function sum(a, b) {
  return a + b;
}

And in another file:

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

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

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

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

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

Terminal
export default function greet(name) {
  return `Hello, ${name}!`;
}

And import it without curly braces:

JavaScript
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”.

JavaScript
import { readFile } from "node:fs/promises";

console.log("Loaded");

Fix: Either switch the project to ESM or use require() in CommonJS.

JavaScript
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”.

JavaScript
const path = require("node:path");

console.log(path.basename("/tmp/file.txt"));

Fix: Use import in ESM files.

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

JavaScript
import { sum } from "./math";

Fix: Include the full file name, including .js for local files.

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

Terminal
export default function slugify(text) {
  return text.toLowerCase().replace(/\s+/g, "-");
}
JavaScript
import { slugify } from "./slugify.js";

Fix: Import default exports without curly braces.

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

JavaScript
// 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.

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

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

JavaScript
// 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.

JavaScript
// 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.

Show solution
JavaScript
// 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.