Last 30 Days
No notifications
Modules let you split code into separate files and import what you need. Node.js supports two module systems:
// math.js — Exporting
module.exports = { add, subtract };
// OR
exports.add = (a, b) => a + b;// app.js — Importing
const math = require('./math');
math.add(2, 3);
// math.mjs — Exporting
export const add = (a, b) => a + b;
export default class Calculator { }// app.mjs — Importing
import Calculator, { add } from './math.mjs';
| Feature | CommonJS | ES Modules |
| Syntax | require() / module.exports | import / export |
| Loading | Synchronous | Asynchronous |
| File ext | .js | .mjs or "type": "module" |
| Top-level await | ❌ | ✅ |
| Used in | Node.js (default) | Browsers + Node.js |
A module is just one file. Splitting your app into small files keeps it understandable and makes pieces reusable. Node loads each file once, runs it, and gives other files a way to grab whatever it exposes.
Node supports two module systems side-by-side:
require / module.exports.import / export.// math.js
function add(a, b) { return a + b; }
function sub(a, b) { return a - b; }module.exports = { add, sub };
// app.js
const { add, sub } = require('./math');
console.log(add(2, 3)); // 5require('./math') — leading ./ means "local file".require('express') — no leading dot means "look in node_modules".// math.mjs (or any .js file in a project with "type": "module")
export function add(a, b) { return a + b; }
export function sub(a, b) { return a - b; }
export default function mul(a, b) { return a * b; }// app.mjs
import mul, { add, sub } from './math.mjs';Key rule: ESM file imports usually need the full filename with extension (./math.mjs), CJS does not.
"type": "module" in package.json).require → CommonJS is still completely fine.node-fetch, chalk). If require complains, your project probably needs to switch to ESM.1. Mixing require and import in the same file. Pick one per file — Node won't let you mix.
2. Forgetting ./ for local files. require('math') looks in node_modules; require('./math') looks next door.
3. module.exports = something *and* exports.x = .... The first replaces the export object entirely, wiping out the second.
4. Circular imports. a.js requires b.js which requires a.js. Half-loaded modules give you confusing undefineds. Refactor to a third file holding the shared piece.
5. Editing a file inside node_modules/. Your changes vanish on the next npm install.
require Actually WorksWhen you call require('./myModule'), Node:
1. Resolves the path (core module? local file? node_modules?)
2. Loads the file contents from disk
3. Wraps the code in a function (the "module wrapper")
4. Executes the wrapped code
5. Caches the result — subsequent requires return the cached export
// Every CJS module is invisibly wrapped in:
(function (exports, require, module, __filename, __dirname) {
// your code lives here
});That wrapper is why __dirname, __filename, require, module, and exports are all magically available.
require('express')
1. Built-in core module? (fs, path, http…) → use that
2. Starts with './' or '/' or '../'? → load as file/folder
3. Otherwise, walk up looking in node_modules:
./node_modules/express
../node_modules/express
../../node_modules/express
… until the filesystem rootFor folders, Node looks for index.js or whatever the folder's own package.json lists in "main".
Because Node caches modules, anything you put at the top level runs once. That makes modules a natural place for singletons like a database connection:
// db.js
let connection = null;
function connect() { if (!connection) connection = createConn(); return connection; }
module.exports = { connect };Every file that does require('./db') gets the same exported object — and so the same connection.
Export a function so callers can configure each instance:
// logger.js
module.exports = (prefix) => ({
log: (m) => console.log([${prefix}] ${m}),
error: (m) => console.error([${prefix}] ERROR ${m}),
});// usage
const logger = require('./logger')('auth');
logger.log('signed in');
package.json and "type"{
"name": "my-app",
"type": "module", // "module" → ESM, "commonjs" (default) → CJS
"main": "src/index.js", // entry point when others import this package
"exports": { // modern alternative — finer control
".": "./src/index.js",
"./utils": "./src/utils.js"
}
}exports lets a library hide internal files and expose only specific subpaths. Required for ESM packages that want to remain importable from CJS too.
// CJS — dynamic require (runtime path)
const mod = require(process.env.PLUGIN);// ESM — dynamic import returns a Promise
const mod = await import('./plugin.mjs');
Use for plugin systems, lazy-loading rarely-used heavy deps, or loading ESM from CJS code.
node: Prefix for Built-insimport fs from 'node:fs/promises';
const path = require('node:path');The node: prefix makes it explicit you mean the built-in (no chance of a naming clash with an npm package). Recommended for new code.
Tired of require('../../../lib/db')? Define internal aliases in package.json:
{
"imports": {
"#db": "./src/lib/db.js",
"#utils/*": "./src/utils/*.js"
}
}import db from '#db';
import { slugify } from '#utils/strings';No bundler required — Node understands # imports natively.
await (ESM only)// config.mjs
const data = await fetch('https://example.com/config').then(r => r.json());
export default data;A huge ergonomic win — no IIFE wrappers needed for setup code. Not available in CommonJS.
1. Create a math.js with add/sub, export them, and use them from index.js (CJS).
2. Convert the same project to ESM ("type": "module", change require → import).
3. Build a tiny logger factory and use it from two different files; confirm both calls share the same prefix you passed.
4. Add an #config subpath import in package.json and import it from a deeply-nested file.