The Node.js Module System: CommonJS vs ES Modules, "type", "exports" and require(esm)
Node.js loads a file as CommonJS or as an ES module based on its extension (.mjs/.cjs) and the "type" field in the nearest package.json; since require(esm) is stable in Node 26, the two worlds can now load each other, except for top-level await. With real errors: extensionless imports, ERR_REQUIRE_ASYNC_MODULE, named imports from CommonJS, paths closed by exports, the dual package hazard, and #-aliases plus JSON import attributes in the Bookstore API.
Version note: The examples in this post were run with Node.js 26.13.1 (LTS).
Guess First
We wrote a small module that formats prices for Bookstore, the online bookstore used as the shared example across this site's series. The folder's package.json has "type": "module", so price.js is an ECMAScript module:
export function formatPrice(amount) {
return `${amount.toFixed(2)} TRY`;
}
We use the function from two files. The first is also an ECMAScript module (guess.js), the second an old-style CommonJS file (guess.cjs). Both write the file name without an extension:
import { formatPrice } from "./price";
console.log(formatPrice(349.9));
const { formatPrice } = require("./price");
console.log(formatPrice(349.9));
Which one works? A hint: one loads an ECMAScript module with import, the other with require. From older knowledge you might think "you cannot require an ECMAScript module".
node guess.js
node guess.cjs
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/home/user/bookstore-api/labs/03/price' imported from /home/user/bookstore-api/labs/03/guess.js
Did you mean to import "./price.js"?
349.90 TRY
It went the other way. import could not find the extensionless path, while require both completed the extension itself and loaded an ECMAScript module without trouble. The first is the resolution rule of ECMAScript modules: paths are written in full and nothing is guessed. The second is require(esm), which lost its flag in Node 22.12 and stopped being experimental in 25.4. This post covers Node's two module systems, which one applies when, the exports and imports fields in package.json, and the imports starting with # that we add to the Bookstore API. Modules in the browser are covered in the JavaScript Intermediate series post on browser modules; here we stick to the rules specific to Node.
Two Module Systems, One Runtime
When Node appeared in 2009, JavaScript had no standard module system. Node adopted a proposal called CommonJS: load with require(), expose with module.exports. Most of the npm ecosystem was written that way. ECMAScript 2015 then brought import/export syntax into the language. This system, ESM for short, works in browsers and in Node and is the default for new code today. Node supports both and decides which one loads a file in this order:
.mjsis always ESM,.cjsis always CommonJS.- For a
.jsfile Node looks at the nearestpackage.jsonabove it:"type": "module"means ESM,"type": "commonjs"means CommonJS. - If there is no
typefield, Node looks at the source; if it seesimport,export,import.metaor top-levelawait, it loads the file as ESM. This syntax detection has been on by default since Node 22.7. .ts,.mtsand.ctsfollow the same rules; we get to the TypeScript side in post 05.
Do not rely on the last rule; detection means an extra parse and tells the reader nothing. That is also why npm 11 writes "type": "commonjs" explicitly in npm init. The Bookstore API uses "type": "module"; we only touch CommonJS when talking to old packages.
CommonJS: require, module.exports and the require Cache
In CommonJS every file runs wrapped in a function; require, module, exports, __dirname and __filename arrive as that function's parameters. require is synchronous: it reads the file, runs it and returns whatever is in module.exports.
// CommonJS: synchronous require, module.exports and module-scoped variables
const path = require("node:path");
let created = 0;
function createCart(customer) {
created++;
return { customer, items: [] };
}
module.exports = { createCart, createdCount: () => created };
console.log("cart.cjs evaluated in", path.basename(__dirname), "as", path.basename(__filename));
const first = require("./cart.cjs");
const second = require("./cart.cjs");
first.createCart("Ada");
first.createCart("Alan");
console.log("same object:", first === second);
console.log("carts created:", second.createdCount());
console.log("cached:", Object.keys(require.cache).map((f) => f.split("/").pop()));
cart.cjs evaluated in 03 as cart.cjs
same object: true
carts created: 2
cached: [ 'cjs-basics.cjs', 'cart.cjs' ]
Both require calls returned the same object, and cart.cjs ran only once. After the first load, modules are kept in require.cache; that is why a variable in a module behaves like a singleton living for the whole process. It is similar to the singleton lifetime in ASP.NET Core's DI container, except there is no container: everyone who loads the same file sees the same instance. The same holds in ESM, where modules are evaluated once.
The difference between module.exports and exports is a classic trap too. Writing exports.x = ... adds a property to the module.exports object; writing exports = { x } only changes the local variable and exposes nothing.
ES Modules: import, export, import.meta and Top-Level Await
// ESM: import/export, import.meta and top-level await
import { readFile } from "node:fs/promises";
import { join } from "node:path";
console.log("dirname:", import.meta.dirname.split("/").pop());
console.log("filename:", import.meta.filename.split("/").pop());
console.log("typeof require:", typeof require);
// Top-level await: no async main() wrapper needed
const books = JSON.parse(await readFile(join(import.meta.dirname, "books.json"), "utf8"));
console.log("books:", books.map((b) => b.title).join(", "));
dirname: 03
filename: esm-basics.js
typeof require: undefined
books: Dune, The Hobbit
There is no require, __dirname or __filename in ESM. import.meta.dirname and import.meta.filename take their place; both were added in Node 20.11 and stopped being experimental in 24. You no longer need the fileURLToPath(import.meta.url) detour you see in older code. If you really need require, you can create one with createRequire(import.meta.url) from node:module.
Top-level await lets you use await at the top level of a module; every module that imports it is not evaluated until that wait finishes. It is convenient for things like reading configuration at startup, but as we will see shortly, it has a cost.
Resolution rules are stricter in ESM as well. Relative imports need the extension (./price.js), there are no folder imports (writing ./routes and expecting index.js), and paths are interpreted like URLs. That is why the error in Guess First suggests "Did you mean to import ./price.js?". Extension completion in CommonJS, by contrast, tries .js, .json and .node in turn.
require(esm): Loading an ECMAScript Module from CommonJS
For years the only way to use an ESM package from CommonJS was dynamic import(), which meant turning code written around synchronous require into async code. require(esm), unflagged in Node 22.12 and stable in 25.4, changed that:
// require(esm): synchronous ESM can be required, ESM with top-level await cannot
const price = require("./price.js");
console.log("price.js via require:", price.formatPrice(289.5));
console.log("require_module:", process.features.require_module);
try {
require("./exchange-rates.js");
} catch (err) {
console.log(err.code);
console.log(err.message.split("\n")[0]);
}
// ESM with top-level await: loads the rates before anyone can use the module
import { setTimeout as delay } from "node:timers/promises";
await delay(10);
export const rates = { EUR: 37.6 };
price.js via require: 289.50 TRY
require_module: true
ERR_REQUIRE_ASYNC_MODULE
require() cannot be used on an ESM graph with top-level await. Use import() instead. To see where the top-level await comes from, use --experimental-print-required-tla.
The rule is simple: because require is synchronous, there must be no top-level await anywhere in the ESM graph being loaded. If there is, you get ERR_REQUIRE_ASYNC_MODULE and fall back to import(). The --experimental-print-required-tla flag from the error message helps you find which file has the await in a deep dependency chain. The practical lesson: if you write a library, avoid top-level await, or you shut out CommonJS users.
Using CommonJS from ESM: When Do Named Imports Work?
The other direction is older and usually painless: you can import a CommonJS module from ESM. Named imports do not always work, though. Here is a module whose exports are built at runtime:
// An old CommonJS module whose exports are built at runtime
function createPricing(vatRate) {
return {
withVat: (amount) => Math.round(amount * (1 + vatRate) * 100) / 100,
};
}
module.exports = createPricing(0.1);
import { withVat } from "./legacy-pricing.cjs";
console.log(withVat(349.9));
SyntaxError: Named export 'withVat' not found. The requested module './legacy-pricing.cjs' is a CommonJS module, which may not support all module.exports as named exports.
CommonJS modules can always be imported via the default export, for example using:
import pkg from './legacy-pricing.cjs';
const { withVat } = pkg;
Node has to know the named imports in ESM before linking; it guesses what a CommonJS module exports by looking at its source, without running it. It recognises patterns such as exports.withVat = ... or module.exports = { withVat }, but it cannot see the object a function returns. The error message gives the fix as well: the default import always works, because it is module.exports itself.
import pricing from "./legacy-pricing.cjs";
console.log(pricing.withVat(349.9));
384.89
package.json "exports": The Door a Package Opens
The exports field decides which files of a package can be imported from outside. The old main field only named the entry point, and any file inside the package could be loaded as package/lib/x.js. With exports present, only the listed paths are open; think of it as the package-level counterpart of internal in .NET. I defined a small package in a lab folder:
{
"name": "bookstore-pricing",
"type": "module",
"exports": {
".": "./price.js",
"./cache": {
"import": "./price-cache.mjs",
"require": "./price-cache.cjs"
}
}
}
A package with exports can import itself by its own name (self-reference). That lets us try the package without installing it into node_modules:
// Self-reference: a package can import itself by name through its "exports"
import { formatPrice } from "bookstore-pricing";
console.log(formatPrice(310));
try {
await import("bookstore-pricing/cart.cjs");
} catch (err) {
console.log(err.code);
console.log(err.message.split(" imported from")[0]);
}
310.00 TRY
ERR_PACKAGE_PATH_NOT_EXPORTED
Package subpath './cart.cjs' is not defined by "exports" in /home/user/bookstore-api/labs/03/package.json
The ./cache entry is a conditional export: loaded with import it picks the .mjs file, loaded with require the .cjs file. Conditions are tried in the order they are written; the types condition for TypeScript goes first, and the catch-all default goes last, as the Node documentation also says.
The Dual Package Hazard
Packages that ship both a CommonJS and an ESM build are called dual packages. They are convenient, but they carry a risk: two copies of the package can be loaded in the same application. The two builds of our price cache each hold a Map. The application writes a price from the ESM side, while an old invoice module written in CommonJS reads the same package through require:
// A CommonJS dependency that reads prices from the cache
const { getPrice } = require("bookstore-pricing/cache");
exports.invoiceLine = (isbn) => `${isbn}: ${getPrice(isbn) ?? "price missing"}`;
import { setPrice, getPrice } from "bookstore-pricing/cache";
import { invoiceLine } from "./invoice.cjs";
setPrice("9780441172719", 349.9);
console.log("ESM side:", getPrice("9780441172719"));
console.log("CJS side:", invoiceLine("9780441172719"));
ESM side: 349.9
CJS side: 9780441172719: price missing
Same package name, two separate module instances, two separate Maps. Any package that holds state, checks instanceof or relies on a singleton can fall into this trap. One fix is to keep the state in one build and make the other a thin wrapper around it. require(esm) opened another route: publish the package as ESM only; CommonJS users can still load it with require.
"imports" and #-Aliases: Applying Them to the Bookstore API
exports are the paths a package opens to the outside; imports are the shortcuts a package uses internally. Their names must start with #, so they cannot clash with npm package names. In the Bookstore API we use them instead of relative paths such as ../../services/bookService.js:
"imports": {
"#routes/*": "./src/routes/*.js",
"#services/*": "./src/services/*.js",
"#db/*": "./src/db/*"
}
Because I put the extension on the target side of the pattern, writing #routes/health in code is enough; Node is not guessing an extension, you defined the mapping. When we move to TypeScript in post 05 we change only the extension in these three lines, and the code stays the same. You can see how Node resolves them with import.meta.resolve:
node --input-type=module -e 'console.log(import.meta.resolve("#routes/health")); console.log(import.meta.resolve("#db/books.json"))'
file:///home/user/bookstore-api/src/routes/health.js
file:///home/user/bookstore-api/src/db/books.json
An alias that is not defined gives ERR_PACKAGE_IMPORT_NOT_DEFINED. The service, which now reads the book list from a JSON file, and the new route look like this:
// In-memory catalog until the SQL Server post; the JSON file is loaded once at startup
import books from "#db/books.json" with { type: "json" };
export function listBooks() {
return books;
}
export function findBook(isbn) {
return books.find((b) => b.isbn === isbn);
}
import { listBooks } from "#services/bookService";
// GET /api/books
export function handleListBooks(req, res) {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(listBooks()));
}
import { createServer } from "node:http";
import { handleHealth } from "#routes/health";
import { handleListBooks } from "#routes/books";
const port = Number(process.env.PORT ?? 3000);
const server = createServer((req, res) => {
if (req.method === "GET" && req.url === "/health") {
return handleHealth(req, res);
}
if (req.method === "GET" && req.url === "/api/books") {
return handleListBooks(req, res);
}
res.writeHead(404, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "not found" }));
});
server.listen(port, () => {
console.log(`Bookstore API listening on http://localhost:${port} (${process.env.NODE_ENV ?? "development"})`);
});
curl http://localhost:3000/api/books
[{"isbn":"9780441172719","title":"Dune","author":"Frank Herbert","price":349.9,"currency":"TRY"},{"isbn":"9780547928227","title":"The Hobbit","author":"J.R.R. Tolkien","price":289.5,"currency":"TRY"},{"isbn":"9780141439600","title":"Middlemarch","author":"George Eliot","price":310,"currency":"TRY"},{"isbn":"9780141439587","title":"Emma","author":"Jane Austen","price":245,"currency":"TRY"}]
JSON Import Attributes
The with { type: "json" } part is an import attribute. It is required when importing a JSON file in ESM; leave it out and Node refuses to load the file:
TypeError [ERR_IMPORT_ATTRIBUTE_MISSING]: Module "file:///home/user/bookstore-api/labs/03/books.json" needs an import attribute of "type: json"
code: 'ERR_IMPORT_ATTRIBUTE_MISSING'
The reason is security: in the browser a server can return JavaScript from a URL ending in .json; the attribute states explicitly that the expected content is data, and Node applies the same rule. JSON modules are loaded once like any other module and the same object is shared; if you mutate it, the whole application sees the change.
In Production
Module loading is a startup cost. Loading a large dependency tree at startup lengthens the time until the container is ready. Loading a heavy, rarely used module (PDF generation, for example) on demand with dynamic import() shortens it.
Use top-level await only in the application's entry point. If you use it in a shared module, everything that requires it breaks with ERR_REQUIRE_ASYNC_MODULE, and every module that imports it inherits the wait.
Module-level state lives as long as the process and is shared across all requests. Keeping a request-specific value (the user, a correlation id) in a module-level variable makes concurrent requests see each other's data. The right tool for request context is AsyncLocalStorage; we set it up in post 07.
Make sure the "type" field and the file extensions survive your production build. If a bundler or a copy step skips package.json, your .js files are loaded as CommonJS and the application stops at the first import line.
Common Pitfalls
Extensionless relative imports in ESM. import "./price" does not work; write ./price.js. Even in TypeScript, as we will see in post 05, you write the extension Node reads.
Importing a CommonJS package with named imports. If the package builds its exports dynamically, the named import throws a SyntaxError; take the default import and pick from it.
Forgetting that adding exports is a breaking change. Adding exports to a package's package.json instantly closes the internal files users imported directly.
Looking for __dirname in ESM. You get a ReferenceError; use import.meta.dirname.
Loading both the ESM and the CommonJS copy of a package without noticing. Two copies of a cache, a logger or a configuration object you expected to be a singleton only show up as odd behaviour in production.
A Small Experiment
★☆☆ Copy labs/03/price.js to price.mjs and delete the "type" line from labs/03/package.json. How do guess.cjs and price.js behave? In which file does syntax detection kick in?
★★☆ Change legacy-pricing.cjs so that it supports the named import while keeping the createPricing function. Hint: write the export in a pattern Node can recognise.
★★★ Rework the bookstore-pricing package to be ESM only, delete price-cache.cjs, and show that invoice.cjs uses the single copy through require(esm). How did the exports map change?
The modules are in place; next comes code from outside. In the next post we look at npm in depth: SemVer ranges, package-lock.json, npm ci, how node_modules is laid out, an npm workspace for Bookstore and supply chain security.