A module is a self-contained unit of code — a file that encapsulates specific functionality and exposes only what it chooses to make available to the rest of the application. The module system is what allows a Node.js application to be split across multiple files, share code between them, and pull in external packages without everything bleeding into a single global scope.
Understanding the module system is foundational. Every Node.js file you write either uses modules, is a module, or both.
Why Modules Exist
In the browser, JavaScript files loaded via <script> tags share a single global scope. A variable declared in one file is visible in every other file. This works for small scripts but becomes unmanageable at scale — names collide, dependencies between files are implicit, and the order in which scripts are loaded becomes critical and fragile.
Node.js was designed without this constraint. Every file in a Node.js application is its own module with its own scope. Variables, functions, and classes defined in a file are private to that file unless explicitly exported. Nothing leaks into a global namespace by accident.
This is what makes large Node.js applications possible to reason about: each file is responsible for what it exposes, and consumers of that file receive only what was deliberately shared.
The Two Module Systems
Node.js supports two distinct module systems, and understanding both is necessary because you will encounter each of them in the wild.
CommonJS (CJS) is the original Node.js module system, introduced when Node.js launched in 2009. It uses require() to load modules and module.exports to export them. It is synchronous — when you call require(), Node.js loads the file immediately before continuing.
ES Modules (ESM) is the standardised module system built into the JavaScript language specification, formalised in ES2015. It uses import and export statements. Node.js added stable support for ES Modules in version 12. ESM is asynchronous by design and is the direction the JavaScript ecosystem is moving.
Both systems are in active use. Most existing Node.js code and packages use CommonJS. Most modern JavaScript tooling and an increasing number of new packages use ES Modules. You need to know both.
CommonJS
Exporting from a Module
In CommonJS, each file has access to a module object. The module.exports property is what the file exposes to anyone who requires it. By default, module.exports is an empty object {}.
Exporting a single value:
// greet.js
function greet(name) {
return `Hello, ${name}`;
}
module.exports = greet;
Exporting multiple values:
// mathUtils.js
function add(a, b) {
return a + b;
}
function subtract(a, b) {
return a - b;
}
module.exports = { add, subtract };
exports is a shorthand reference to module.exports. You can add properties to it directly:
// mathUtils.js
exports.add = function(a, b) {
return a + b;
};
exports.subtract = function(a, b) {
return a - b;
};
One important caveat: exports and module.exports point to the same object initially, but if you reassign exports entirely — exports = something — you break that reference and nothing gets exported. Always reassign module.exports if you need to replace the export entirely, and use exports.property only for adding to the existing object.
Requiring a Module
require() loads a module and returns whatever that module's module.exports is set to.
Loading a local file:
// app.js
const greet = require('./greet');
const { add, subtract } = require('./mathUtils');
console.log(greet('Wariz')); // Hello, Wariz
console.log(add(10, 5)); // 15
console.log(subtract(10, 5)); // 5
The ./ prefix tells Node.js this is a local file relative to the current file. Without it, Node.js looks for a built-in core module or an installed npm package instead.
Loading a core module:
const fs = require('fs');
const path = require('path');
Loading an npm package:
const express = require('express');
How require() Resolves Modules
When you call require(), Node.js follows a specific resolution process to find the module:
- Core modules — if the name matches a built-in module (
fs,path,http, etc.), it is returned immediately. - File paths — if the path starts with
./,../, or/, Node.js treats it as a file path. It looks for:- The exact path
- The path with
.js,.json, or.nodeappended - A directory with an
index.jsfile inside it
node_modules— if neither of the above applies, Node.js searches for the name in thenode_modulesfolder, starting in the current directory and walking up the directory tree until it finds a match or reaches the filesystem root.
Module Caching
CommonJS modules are cached after the first require() call. If two different files both require the same module, Node.js loads it once and returns the same cached export to both. The module's code does not run twice.
// counter.js
let count = 0;
module.exports = {
increment: () => ++count,
getCount: () => count,
};
// a.js
const counter = require('./counter');
counter.increment();
// b.js
const counter = require('./counter');
console.log(counter.getCount()); // 1 — same instance as a.js
This is intentional behaviour. It means modules can maintain state, and that state is shared across every file that requires them.
ES Modules
Exporting from a Module
ES Modules use the export keyword. There are two kinds of exports: named exports and a default export.
Named exports — export multiple values by name:
// mathUtils.js
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
export const PI = 3.14159;
Default export — export a single primary value:
// greet.js
export default function greet(name) {
return `Hello, ${name}`;
}
A module can have both named exports and a default export, though it is common to use one or the other consistently.
Importing a Module
// Named imports
import { add, subtract, PI } from './mathUtils.js';
// Default import
import greet from './greet.js';
// Rename a named import
import { add as sum } from './mathUtils.js';
// Import everything into a namespace object
import * as math from './mathUtils.js';
console.log(math.add(2, 3)); // 5
Note that file extensions are required in ESM import paths — './mathUtils.js', not './mathUtils'. This is a meaningful difference from CommonJS, where the extension is optional.
Enabling ES Modules in Node.js
Node.js does not treat .js files as ES Modules by default — it treats them as CommonJS. There are two ways to use ES Modules:
Option 1 — Set "type": "module" in package.json:
{
"type": "module"
}
With this set, all .js files in the project are treated as ES Modules. CommonJS files must use the .cjs extension.
Option 2 — Use the .mjs extension:
Name the file with a .mjs extension instead of .js. Node.js will always treat .mjs files as ES Modules regardless of the package.json setting.
| File extension | Module system |
|---|---|
.js (default) | CommonJS |
.js with "type": "module" in package.json | ES Module |
.mjs | Always ES Module |
.cjs | Always CommonJS |
Top-Level await
One practical advantage of ES Modules is support for top-level await — using await outside of an async function, directly at the top level of a module file:
// config.mjs
const response = await fetch('https://api.example.com/config');
const config = await response.json();
export default config;
This is not possible in CommonJS because require() is synchronous and has no mechanism for waiting on asynchronous operations at the top level.
CommonJS vs. ES Modules — Key Differences
| CommonJS | ES Modules | |
|---|---|---|
| Syntax | require() / module.exports | import / export |
| Loading | Synchronous | Asynchronous |
| File extension | .js or .cjs | .mjs or .js with "type": "module" |
| File extensions in imports | Optional | Required |
Top-level await | Not supported | Supported |
| Default in Node.js | Yes | No (opt-in) |
| Tree-shaking | Not possible | Possible — bundlers can eliminate unused exports |
__dirname and __filename | Available | Not available — use import.meta.url |
| Caching | Yes | Yes |
Interoperability
CommonJS and ES Modules can coexist within a project, but mixing them comes with constraints:
- An ES Module can import a CommonJS module — Node.js treats the CommonJS
module.exportsvalue as the default export. - A CommonJS module cannot use
require()to load an ES Module —require()is synchronous and cannot handle the asynchronous nature of ESM loading. You can use a dynamicimport()instead (see below).
Dynamic Imports
Both module systems support dynamic imports — loading a module at runtime rather than at the top of the file. This uses the import() function, which returns a Promise:
// Works in both CommonJS and ES Module files
async function loadModule() {
const { add } = await import('./mathUtils.js');
console.log(add(2, 3));
}
Dynamic imports are useful for loading modules conditionally or on demand — for example, only loading a large library when a specific feature is used.
Built-in Module Variables in CommonJS
CommonJS provides several variables that are available in every module automatically:
| Variable | What It Contains |
|---|---|
__filename | The absolute path of the current file |
__dirname | The absolute path of the directory containing the current file |
module | The current module object |
module.exports | What the module exposes to require() |
exports | A reference to module.exports |
require | The function used to load other modules |
console.log(__filename); // /home/wariz/project/app.js
console.log(__dirname); // /home/wariz/project
In ES Modules, __filename and __dirname are not available. The equivalent is derived from import.meta.url:
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
Which System Should You Use?
For new projects, ES Modules is the right default. It is the language standard, it is where tooling is headed, and it enables features — top-level await, tree-shaking — that CommonJS cannot offer. Set "type": "module" in package.json and write import/export throughout.
For projects using frameworks or tools that are still CommonJS-first — and there are many — use CommonJS. Fighting the framework's module system to use ESM creates friction without meaningful benefit.
For packages you publish to npm, the recommended approach is to ship both — a CommonJS build and an ES Module build — so consumers can use whichever system their project requires. Tooling like tsup and rollup can handle this automatically.
Summary
| Concept | CommonJS | ES Modules |
|---|---|---|
| Load a module | require('./module') | import { thing } from './module.js' |
| Export a value | module.exports = value | export default value |
| Export multiple values | module.exports = { a, b } | export function a() {} / export const b = ... |
| Dynamic load | import('./module') | import('./module.js') |
| Enable in Node.js | Default | "type": "module" or .mjs extension |