esbuild’s default behavior for CommonJS and ESM interop is surprisingly effective because it performs a deep analysis of your module graph, not just a superficial check.
Let’s see it in action. Imagine you have a project structure like this:
.
├── src/
│ ├── cjs_module.js
│ └── index.js
└── package.json
src/cjs_module.js is a CommonJS module:
const message = "Hello from CommonJS!";
function greet() {
return message;
}
module.exports = { greet };
And src/index.js is an ESM module that imports cjs_module.js:
import { greet } from './cjs_module.js';
console.log(greet());
Your package.json might look like this:
{
"name": "cjs-esm-interop",
"version": "1.0.0",
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "esbuild src/index.js --bundle --outfile=dist/index.js --format=esm"
},
"dependencies": {}
}
Notice "type": "module" in package.json, which tells Node.js to treat .js files as ESM by default. However, esbuild is smart enough to detect module.exports and require within cjs_module.js.
When you run npm run build, esbuild will process this. The output dist/index.js will look something like this:
// src/cjs_module.js
var require = typeof require !== "undefined" ? require : (function(moduleName) {
var module = { exports: {} };
if (moduleName === "./cjs_module.js") {
var message = "Hello from CommonJS!";
function greet() {
return message;
}
module.exports = { greet };
}
return module.exports;
});
var cjs_module_exports = require("./cjs_module.js");
// src/index.js
var greet = cjs_module_exports.greet;
console.log(greet());
esbuild has effectively "inlined" the CommonJS module’s logic and exposed its exports as if it were a standard ESM import. It handles the require call by creating a shim if necessary and then processes the exports.
The fundamental problem this solves is bridging the gap between the two dominant JavaScript module systems. ESM (ECMAScript Modules) is the modern, static, and asynchronous standard, while CommonJS (CJS) is the older, synchronous system primarily used in Node.js. When you have a project that uses both, or when you’re trying to bundle code that depends on CJS libraries into an ESM output, you need a bundler that understands how to translate between them.
Internally, esbuild performs a static analysis of your entire module graph. For each module, it determines its type (ESM or CJS) based on file extensions, package.json’s "type" field, and the presence of import/export statements versus require/module.exports. When it encounters an import statement in an ESM module that points to a CJS module, it doesn’t just throw an error. Instead, it analyzes the CJS module’s module.exports and recreates that export interface in the generated output. For require calls within CJS modules that import ESM modules, esbuild transforms the ESM module into a CJS-compatible structure.
The key levers you control are the --format flag for the output (e.g., esm, cjs, iife) and esbuild’s implicit handling of input module types. You typically don’t need to explicitly tell esbuild "this is CJS" or "this is ESM" if your project structure and package.json are set up correctly. esbuild infers it.
The most surprising thing most people don’t realize is that esbuild can bundle CJS require calls that dynamically import other modules. For instance, if a CJS module uses require(variable), esbuild often resolves this by performing its static analysis and bundling the likely candidates, or by wrapping the dynamic require in a shim that mimics Node.js’s behavior. This means that even complex, older CJS codebases can be bundled into modern formats without manual intervention, provided the dynamic requires are resolvable at build time.
The next step is often dealing with Node.js built-in modules when bundling for non-Node.js environments.