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.

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:

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

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

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

11. Practice Exercise

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.