The Node.js Module System — CommonJS vs ES Modules

▶ Try It Yourself

Every file in a Node.js application is a module — an isolated unit of code with its own scope. The module system controls how code is shared between files: what one file exports, another can import. Node.js has two module systems: CommonJS (CJS) — the original Node.js module format using require() and module.exports — and ES Modules (ESM) — the official JavaScript standard using import and export. Understanding both, when to use each, and how they interact is critical because the MEAN Stack ecosystem is in the middle of a migration, and you will encounter both systems in real projects.

CommonJS vs ES Modules — Side by Side

Feature CommonJS (CJS) ES Modules (ESM)
Export syntax module.exports = ... or exports.fn = ... export default ... or export const fn = ...
Import syntax const x = require('./module') import x from './module.js'
Loading Synchronous — executes immediately Asynchronous — parsed statically
File extension .js or .cjs .mjs or .js with "type":"module" in package.json
Top-level await Not supported Supported
Tree-shaking Not possible — dynamic Possible — static analysis
__dirname Available Not available — use import.meta.url
require() Available Not available (use createRequire)
Named exports Via exports.name = ... Via export const name = ...
Default export module.exports = value export default value
Circular deps Partially supported — may get incomplete exports Better handling via live bindings
Ecosystem support Universal — all Node.js packages Growing — most major packages now support

When to Use Which

Scenario Recommended Reason
New Node.js project (2025+) ESM Future standard, top-level await, tree-shakeable
Existing Express/Node project CJS Avoid migration cost — CJS works perfectly
npm package for others Dual CJS + ESM Support both consumers
Angular / frontend ESM always Angular uses TypeScript with ESM import/export
Config files (jest.config, etc.) CJS Many tools still require CJS config files
Note: To enable ES Modules in a Node.js project, add "type": "module" to package.json. All .js files in the project are then treated as ESM. If you need some files to remain CommonJS, use the .cjs extension for them. Conversely, you can use ESM files in a CJS project by giving them the .mjs extension. The simplest rule: pick one format per project and be consistent.
Tip: In ESM, file extensions are required in import paths. import { handler } from './auth' fails in Node.js ESM — you must write import { handler } from './auth.js'. This differs from TypeScript where extensions are omitted. When using TypeScript with Node.js ESM, write import { handler } from './auth.js' in your .ts files — TypeScript understands this and resolves to auth.ts.
Warning: You cannot require() an ES Module from a CommonJS file — it will throw a ERR_REQUIRE_ESM error. You can, however, use dynamic import() from CommonJS to load an ESM module asynchronously: const { fn } = await import('./esm-module.js'). This is the correct interop path when you must mix the two systems.

Basic Example

// ══════════════════════════════════════════════════════
// COMMONJS — the traditional Node.js module system
// ══════════════════════════════════════════════════════

// math.js — CommonJS exports
const PI = 3.14159;

function add(a, b)      { return a + b; }
function subtract(a, b) { return a - b; }
function multiply(a, b) { return a * b; }

// Export an object (named exports)
module.exports = { add, subtract, multiply, PI };

// OR export individually via exports shorthand
exports.add      = add;
exports.subtract = subtract;
// Note: never mix `module.exports = {...}` with `exports.x = ...` — module.exports wins

// app.js — CommonJS imports
const math   = require('./math');           // import entire module
const { add } = require('./math');          // destructure named export

console.log(math.add(2, 3));               // 5
console.log(add(2, 3));                    // 5
console.log(require('./math').PI);         // 3.14159

// Require a built-in module
const path = require('path');

// Require an npm package
const express = require('express');

// ── CJS Module Caching ────────────────────────────────────────────────────
// require() caches modules — the same instance is returned every time
const configA = require('./config');
const configB = require('./config');
console.log(configA === configB);   // true — same object reference

// ══════════════════════════════════════════════════════
// ES MODULES — the modern JavaScript standard
// ══════════════════════════════════════════════════════

// math.mjs — ES Module exports
export const PI = 3.14159;

export function add(a, b)      { return a + b; }
export function subtract(a, b) { return a - b; }

export default class Calculator {
    add(a, b) { return a + b; }
}

// app.mjs — ES Module imports
import Calculator, { add, PI } from './math.mjs';  // default + named
import * as math from './math.mjs';                 // import all as namespace

console.log(add(2, 3));          // 5
console.log(math.PI);            // 3.14159
const calc = new Calculator();

// Dynamic import — lazy load (works in both CJS and ESM)
const { default: Calculator2 } = await import('./math.mjs');

// ── __dirname equivalent in ESM ───────────────────────────────────────────
import { fileURLToPath } from 'url';
import { dirname, join }  from 'path';

const __filename = fileURLToPath(import.meta.url);
const __dirname  = dirname(__filename);

const configPath = join(__dirname, 'config', 'settings.json');

// ── Top-level await in ESM (not available in CJS) ─────────────────────────
import { readFile } from 'fs/promises';

const config = JSON.parse(await readFile('./config.json', 'utf8'));
// No need to wrap in an async IIFE — top-level await works natively in ESM

Configuring a Node.js Project for ESM

// package.json — enable ES Modules for all .js files
{
  "name": "my-app",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node src/index.js",
    "dev":   "node --watch src/index.js"
  }
}
// src/index.js — ESM entry point (type: module in package.json)
import express        from 'express';
import { connect }   from 'mongoose';
import { readFile }  from 'fs/promises';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';

const __dirname = dirname(fileURLToPath(import.meta.url));

// Top-level await — load config before starting server
const config  = JSON.parse(
    await readFile(join(__dirname, '..', '.env.json'), 'utf8')
);

const app = express();
await connect(config.mongoUri);
app.listen(config.port, () => console.log(`Server on port ${config.port}`));

Real-World Example: Feature Module Pattern

// ── CommonJS — Feature Module Structure (typical Express project) ─────────

// features/tasks/task.model.js
const mongoose = require('mongoose');

const taskSchema = new mongoose.Schema({
    title:     { type: String, required: true },
    completed: { type: Boolean, default: false },
    user:      { type: mongoose.Types.ObjectId, ref: 'User' },
}, { timestamps: true });

module.exports = mongoose.model('Task', taskSchema);

// features/tasks/task.service.js
const Task = require('./task.model');

const TaskService = {
    async findAll(userId)      { return Task.find({ user: userId }); },
    async findById(id)         { return Task.findById(id); },
    async create(data)         { return Task.create(data); },
    async update(id, data)     { return Task.findByIdAndUpdate(id, data, { new: true }); },
    async remove(id)           { return Task.findByIdAndDelete(id); },
};

module.exports = TaskService;

// features/tasks/task.controller.js
const TaskService  = require('./task.service');
const asyncHandler = require('../../utils/asyncHandler');

exports.getAll = asyncHandler(async (req, res) => {
    const tasks = await TaskService.findAll(req.user.id);
    res.json({ success: true, data: tasks });
});

exports.create = asyncHandler(async (req, res) => {
    const task = await TaskService.create({ ...req.body, user: req.user.id });
    res.status(201).json({ success: true, data: task });
});

// features/tasks/task.routes.js
const express    = require('express');
const controller = require('./task.controller');
const auth       = require('../../middleware/authenticate');
const router     = express.Router();

router.use(auth);              // protect all task routes
router.get('/',   controller.getAll);
router.post('/',  controller.create);

module.exports = router;

// app.js
const taskRoutes = require('./features/tasks/task.routes');
app.use('/api/tasks', taskRoutes);

Common Mistakes

Mistake 1 — Mixing module.exports and exports

❌ Wrong — module.exports overwrites the exports shorthand object:

exports.add = (a, b) => a + b;         // sets exports.add
module.exports = { multiply: (a, b) => a * b };  // replaces entire exports!
// exports.add is now GONE — only multiply is exported

✅ Correct — use one or the other consistently:

// Option A: module.exports object
module.exports = {
    add:      (a, b) => a + b,
    multiply: (a, b) => a * b,
};

// Option B: exports shorthand (only for adding properties)
exports.add      = (a, b) => a + b;
exports.multiply = (a, b) => a * b;

Mistake 2 — Missing .js extension in ESM imports

❌ Wrong — ESM requires explicit file extensions in Node.js:

import { handler } from './auth';       // ERR_MODULE_NOT_FOUND in Node.js ESM
import { handler } from './auth.ts';    // also wrong — TypeScript compiles to .js

✅ Correct — always include .js extension (TypeScript resolves .ts from .js):

import { handler } from './auth.js';    // correct in both Node.js ESM and TypeScript

Mistake 3 — Trying to require() an ESM package

❌ Wrong — ERR_REQUIRE_ESM thrown at runtime:

// In a CommonJS file:
const chalk = require('chalk');   // chalk v5+ is ESM-only — this crashes!

✅ Correct — use dynamic import() or choose an older CJS-compatible version:

// Option A: dynamic import (async)
const { default: chalk } = await import('chalk');

// Option B: install the last CJS version
// npm install chalk@4   (chalk@4 is CommonJS)

Quick Reference

Task CommonJS ES Modules
Default export module.exports = value export default value
Named export exports.name = value export const name = value
Import default const x = require('./m') import x from './m.js'
Import named const { a } = require('./m') import { a } from './m.js'
Import all const m = require('./m') import * as m from './m.js'
Dynamic import await import('./m.js') await import('./m.js')
Current directory __dirname dirname(fileURLToPath(import.meta.url))
Enable ESM project "type": "module" in package.json

🧠 Test Yourself

You have a CommonJS Node.js project and you need to use a package that is ESM-only. What is the correct approach?





▶ Try It Yourself