Last updated: 2026-09-13

The Node.js Module System

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:

  1. Core modules — if the name matches a built-in module (fs, path, http, etc.), it is returned immediately.
  2. 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 .node appended
    • A directory with an index.js file inside it
  3. node_modules — if neither of the above applies, Node.js searches for the name in the node_modules folder, 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 extensionModule system
.js (default)CommonJS
.js with "type": "module" in package.jsonES Module
.mjsAlways ES Module
.cjsAlways 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

CommonJSES Modules
Syntaxrequire() / module.exportsimport / export
LoadingSynchronousAsynchronous
File extension.js or .cjs.mjs or .js with "type": "module"
File extensions in importsOptionalRequired
Top-level awaitNot supportedSupported
Default in Node.jsYesNo (opt-in)
Tree-shakingNot possiblePossible — bundlers can eliminate unused exports
__dirname and __filenameAvailableNot available — use import.meta.url
CachingYesYes

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.exports value 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 dynamic import() 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:

VariableWhat It Contains
__filenameThe absolute path of the current file
__dirnameThe absolute path of the directory containing the current file
moduleThe current module object
module.exportsWhat the module exposes to require()
exportsA reference to module.exports
requireThe 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

ConceptCommonJSES Modules
Load a modulerequire('./module')import { thing } from './module.js'
Export a valuemodule.exports = valueexport default value
Export multiple valuesmodule.exports = { a, b }export function a() {} / export const b = ...
Dynamic loadimport('./module')import('./module.js')
Enable in Node.jsDefault"type": "module" or .mjs extension