Skip to content
Codeloom
Node.js

Node.js Modules: CommonJS vs ES Modules

A practical comparison of CommonJS and ES Modules in Node — require vs import, module.exports vs export, the type field in package.json, and the interop traps to avoid.

·8 min read · By Codeloom
Beginner 10 min read

What you'll learn

  • How CommonJS works — require, module.exports, and exports
  • How ES Modules work — import and export
  • What "type": "module" does in package.json
  • When to use .cjs and .mjs file extensions
  • The interop rules between the two systems and where they break
  • Which module system to pick for new projects

Prerequisites

Every non-trivial Node program is split across multiple files. The way those files share code is called a module system, and Node has two of them: the original CommonJS (CJS) and the newer ES Modules (ESM). They look similar, behave differently, and meet at awkward edges.

This post explains both, shows the gotchas, and gives you a clear default for new code.

Why two systems exist

When Node launched in 2009, JavaScript had no standard module system. Node invented CommonJSrequire and module.exports — and it served the ecosystem for a decade.

In 2015, the language itself standardised ES Modulesimport and export. The browser adopted them; bundlers adopted them; eventually Node added support in 2019. Today both work, but new projects increasingly default to ESM.

CommonJS in one example

A CJS module exports values by assigning to module.exports. Another file imports them with require.

// math.js
function add(a, b) {
  return a + b;
}

function multiply(a, b) {
  return a * b;
}

module.exports = { add, multiply };
// app.js
const { add, multiply } = require('./math');

console.log(add(2, 3));        // 5
console.log(multiply(4, 5));   // 20

Run it:

node app.js
# output:
# 5
# 20

A few things to notice:

  • require takes a path relative to the current file (./math), and the .js extension is optional.
  • module.exports is whatever this file makes available — usually an object, but it can be anything (a function, a class, a string).
  • The shorthand exports.add = ... works, but assigning a whole new value to exports does not. Use module.exports when in doubt.

CJS is synchronous. require reads the file, runs it top to bottom, caches the result, and returns it — all before the next line of your code executes.

ES Modules in one example

The same module in ESM:

// math.js
export function add(a, b) {
  return a + b;
}

export function multiply(a, b) {
  return a * b;
}
// app.js
import { add, multiply } from './math.js';

console.log(add(2, 3));        // 5
console.log(multiply(4, 5));   // 20

Two differences jump out:

  • export and import replace module.exports and require.
  • The file extension .js is required in the import path. ESM does not auto-resolve extensions the way CJS does.

To run this, Node needs to know your project uses ESM. Add "type": "module" to your package.json:

{
  "type": "module"
}

Now node app.js works. Without the type field, Node treats .js files as CommonJS by default.

The type field in package.json

This single field decides how Node interprets your .js files:

  • "type": "commonjs" (or omitted) — .js files are CommonJS
  • "type": "module".js files are ES Modules

The type field applies to all .js files inside the package, including subdirectories, until another package.json overrides it.

File extensions: .cjs and .mjs

You can override the package-level choice on a per-file basis with extensions:

  • .cjs — always CommonJS, regardless of "type"
  • .mjs — always ES Modules, regardless of "type"
  • .js — follows the "type" field

This is useful when you need to mix systems, usually because a tool requires CJS while the rest of your code is ESM.

my-project/
├── package.json          # "type": "module"
├── server.js             # ESM
├── utils.js              # ESM
└── legacy-config.cjs     # CommonJS

Default exports

Both systems support a single “default” export, but they spell it differently.

CommonJS:

// logger.js
module.exports = function log(message) {
  console.log(`[LOG] ${message}`);
};
// app.js
const log = require('./logger');
log('hello');   // [LOG] hello

ES Modules:

// logger.js
export default function log(message) {
  console.log(`[LOG] ${message}`);
}
// app.js
import log from './logger.js';
log('hello');   // [LOG] hello

A module can have one default export and any number of named exports:

// utils.js
export default function main() { /* ... */ }
export function helper() { /* ... */ }
export const VERSION = '1.0.0';
import main, { helper, VERSION } from './utils.js';

Try it yourself. Create a folder with package.json containing { "type": "module" }. Write greet.js that exports a greet(name) function, then index.js that imports and calls it. Run node index.js. Then rename greet.js to greet.cjs and watch Node complain — fix it by converting the export to module.exports.

Built-in modules

Node ships with modules like fs, path, and http. Both systems can import them:

// CommonJS
const fs = require('fs');
const { readFile } = require('fs/promises');
// ESM
import fs from 'node:fs';
import { readFile } from 'node:fs/promises';

The node: prefix is the modern, explicit form. It tells the reader (and the runtime) that this is a built-in, not a package from npm. Prefer it in new code.

Top-level await

ES Modules support await at the top level of a file. CommonJS does not.

// fetch-data.mjs
const res = await fetch('https://api.example.com/data');
const data = await res.json();
console.log(data);

This alone is a strong reason to choose ESM for new projects — it makes startup scripts and configuration files dramatically cleaner.

Interop: importing CJS from ESM

You can import a CommonJS module from ESM, but with rules:

// CJS file: math.cjs
module.exports = { add: (a, b) => a + b };
// ESM file
import math from './math.cjs';
console.log(math.add(2, 3));   // 5

The whole module.exports object comes in as the default import. Named destructuring sometimes works (Node tries to detect the shape) but is not guaranteed:

// May work, may not — depends on the CJS module's shape
import { add } from './math.cjs';

The safe pattern is to import the default, then destructure:

import math from './math.cjs';
const { add } = math;

Interop: importing ESM from CJS

The other direction is harder. CommonJS require cannot synchronously load an ES Module — ESM is async. You must use dynamic import:

// CJS file
async function main() {
  const { add } = await import('./math.mjs');
  console.log(add(2, 3));
}

main();

This catches people out when a popular library goes “ESM-only” — suddenly older CJS codebases cannot require it any more. Dynamic import() is the workaround.

__dirname and __filename

CommonJS gives you two magic variables for the current file’s path:

console.log(__dirname);    // /Users/me/project
console.log(__filename);   // /Users/me/project/app.js

ESM does not. Use import.meta.url and the node:url module:

import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';

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

Recent Node versions add import.meta.dirname and import.meta.filename directly, which is much nicer:

console.log(import.meta.dirname);   // /Users/me/project
console.log(import.meta.filename);  // /Users/me/project/app.js

We cover this in detail in Node.js fs and path.

JSON imports

CommonJS lets you require a JSON file directly:

const config = require('./config.json');

ESM needs an import attribute:

import config from './config.json' with { type: 'json' };

The syntax has evolved (older Node used assert instead of with). Check node --version if it complains.

Try it yourself. Create an ESM project. Import node:fs/promises, write a top-level await readFile('./package.json', 'utf-8'), parse the JSON, and log the name field. Notice that you never wrap any of this in async function main() — top-level await makes the whole file behave like an async body.

Which should you choose

For new code: use ES Modules. The reasons:

  • It is the language standard. The same syntax works in browsers, Deno, Bun, and bundlers.
  • Top-level await is genuinely useful.
  • New popular libraries are increasingly ESM-only.
  • The tooling story is mature now — what was painful in 2020 is fine in 2026.

Stay with CommonJS only if:

  • You are working in an existing CJS codebase and a migration is not worth the disruption
  • You depend on a specific tool that does not yet support ESM
  • You need the synchronous require semantics for an unusual reason (rare)

Recap

You now know:

  • CommonJS uses require and module.exports and is synchronous
  • ES Modules use import/export and are the language standard
  • "type": "module" in package.json switches .js files to ESM
  • .cjs and .mjs extensions force a specific system per file
  • ESM unlocks top-level await and uses import.meta.url for paths
  • Importing CJS from ESM is easy; importing ESM from CJS needs dynamic import()
  • Default to ESM for new projects

Next steps

Modules let you split a single project across files. To use other people’s code — frameworks, utilities, database drivers — you need npm.

Next: npm and package.json Explained

Questions or feedback? Email codeloomdevv@gmail.com.