iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

CJS vs ESM

Node has two module systems — CommonJS (require) and ECMAScript Modules (import). New projects should be ESM. Knowing how each resolves and interops with the other prevents the most common Node startup errors.

CJS + ESM + interop + resolution

EXAMPLE
// 1) ESM — the modern default
// package.json:  { "type": "module" }

// math.js
export function add(a, b) { return a + b; }
export function mul(a, b) { return a * b; }
export const PI = 3.14159;
export default { add, mul };

// app.js
import defaultExport, { add, mul, PI } from './math.js';
import * as math from './math.js';
console.log(add(2, 3), math.mul(2, 3), PI);

// IMPORTANT: ESM requires file EXTENSIONS in relative imports.
//   import x from './math'   // ❌ ERR_MODULE_NOT_FOUND
//   import x from './math.js' // ✓

// 2) CommonJS — older code, scripts, many tools still
// package.json:  { "type": "commonjs" }   (or no type field)

// math.cjs
function add(a, b) { return a + b; }
module.exports = { add };
module.exports.PI = 3.14159;

// app.cjs
const { add, PI } = require('./math.cjs');
const all          = require('./math.cjs');

// CJS resolves bare paths and extensions automatically:
//   require('./math')  // finds math.js / math.cjs / math/index.js

// 3) Dynamic import — works in BOTH CJS and ESM
const { add } = await import('./math.js');
// Use case: conditional plugin loading, lazy heavy deps, top-level await in CJS scripts

// 4) ESM <-> CJS interop
// ESM importing CJS — fine, single default export
import pkg from 'some-cjs-lib';
const { foo, bar } = pkg;          // destructure off the default
// or named (Node guesses when CJS sets module.exports = { foo, bar }):
import { foo, bar } from 'some-cjs-lib';

// CJS importing ESM — must be dynamic, ESM cannot be require()'d
const esm = await import('some-esm-lib');
// You CANNOT do: const x = require('some-esm-lib'); → ERR_REQUIRE_ESM

// 5) File extensions as a tell
//   .mjs  → always ESM, ignores package.json
//   .cjs  → always CommonJS, ignores package.json
//   .js   → whichever "type" says in the NEAREST package.json

// 6) Path resolution
import path from 'node:path';
import { fileURLToPath } from 'node:url';

// __dirname / __filename don't exist in ESM. Rebuild them:
const __filename = fileURLToPath(import.meta.url);
const __dirname  = path.dirname(__filename);

// 7) Node-specific imports — prefer the 'node:' prefix
import fs   from 'node:fs/promises';
import http from 'node:http';
import { Buffer } from 'node:buffer';
// Explicit 'node:' avoids accidentally shadowing builtins with a userland package.

// 8) Package entry points (subpath exports)
// package.json
{
    "name": "my-lib",
    "type": "module",
    "exports": {
        ".":         { "import": "./dist/index.js", "require": "./dist/index.cjs", "types": "./dist/index.d.ts" },
        "./helpers": { "import": "./dist/helpers.js" }
    }
}
// Now consumers do:  import x from 'my-lib'  /  import h from 'my-lib/helpers'
// Anything not listed in "exports" is INACCESSIBLE — deliberate encapsulation.

// 9) Top-level await — ESM only
// app.js  (ESM)
const config = await fetch('https://config.example.com').then((r) => r.json());
export default config;
// In CJS, wrap in an async IIFE.

// 10) import.meta — ESM only
console.log(import.meta.url);                              // file:// URL of current module
import.meta.resolve('./other.js');                          // absolute URL

// 11) Conditional exports per environment
{
    "exports": {
        ".": {
            "node":    "./dist/node.js",
            "browser": "./dist/browser.js",
            "default": "./dist/index.js"
        }
    }
}
// Bundlers (webpack, vite, esbuild) read these to pick the right build.

// 12) Common errors and fixes
//   ERR_MODULE_NOT_FOUND               → add the file extension in the import
//   ERR_REQUIRE_ESM                    → use await import(), or rewrite caller as ESM
//   SyntaxError: Cannot use import...  → set "type": "module" or rename to .mjs
//   __dirname is not defined           → rebuild from fileURLToPath(import.meta.url)
//   Unexpected token 'export'          → file is being loaded as CJS

// 13) Recommended setup for new projects
//   • package.json: "type": "module"
//   • use "node:" prefix for builtins
//   • use "exports" map for public APIs
//   • TypeScript: "module": "NodeNext", "moduleResolution": "NodeNext"
//   • Always include extensions in relative imports

Why it matters

Default to ESM in new code; use node:-prefixed builtins; include the file extension in every relative import. When you must consume CJS-only packages, default-import them and destructure, and when CJS code must reach ESM, fall back to dynamic await import().

Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.

Example

Example
// CommonJS
const fs = require('fs');
// ESM (set "type": "module" in package.json)
import fs from 'node:fs';
Try it Yourself »

Exercise

In package.json, opt in to ESM with…

"type": " "

Test yourself

Q1. Modern modules are…
Q2. ESM import paths typically end with…
Q3. CommonJS uses…

Discussion

Loading…