esbuild’s output format flags are surprisingly nuanced, and understanding them is key to avoiding runtime errors and ensuring your code behaves as expected in different environments.
Let’s see esbuild in action producing different module formats.
# ESM Output
esbuild --bundle --outfile=dist/index.esm.js --format=esm src/index.js
# CJS Output
esbuild --bundle --outfile=dist/index.cjs.js --format=cjs src/index.js
# IIFE Output
esbuild --bundle --outfile=dist/index.iife.js --format=iife src/index.js
Consider a simple src/index.js file:
// src/index.js
export const greet = (name) => `Hello, ${name}!`;
export const farewell = (name) => `Goodbye, ${name}!`;
When we build this with --format=esm, the output dist/index.esm.js will look like this:
// dist/index.esm.js
// ... esbuild boilerplate ...
var greet = (name) => `Hello, ${name}!`;
var farewell = (name) => `Goodbye, ${name}!`;
export {
farewell,
greet
};
// ... esbuild boilerplate ...
This is standard ECMAScript Module syntax. You’d typically use this in modern Node.js environments (with "type": "module" in package.json) or in browsers.
Now, let’s build it with --format=cjs:
// dist/index.cjs.js
// ... esbuild boilerplate ...
var greet = (name) => `Hello, ${name}!`;
var farewell = (name) => `Goodbye, ${name}!`;
module.exports = {
farewell,
greet
};
// ... esbuild boilerplate ...
This is CommonJS, the module system historically used in Node.js. The module.exports object holds the exported values. This is suitable for older Node.js versions or when you explicitly need CJS.
Finally, --format=iife (Immediately Invoked Function Expression):
// dist/index.iife.js
var dist = (() => {
// ... esbuild boilerplate ...
var greet = (name) => `Hello, ${name}!`;
var farewell = (name) => `Goodbye, ${name}!`;
return {
farewell,
greet
};
})();
// ... esbuild boilerplate ...
This wraps your code in a self-executing function. The exports are returned by this function and assigned to the dist global variable (or whatever name you specify with --global-name). This is ideal for browser scripts that aren’t using modules or for creating standalone libraries that inject themselves into the global scope.
The primary problem esbuild’s output formats solve is ensuring that your bundled code can be correctly interpreted and executed by the target JavaScript environment. Different environments (Node.js versions, browsers, bundlers) expect different module syntaxes. ESM is the modern standard, CJS is Node.js’s legacy, and IIFE is for global script execution. Choosing the right format prevents "undefined is not a function" errors or incorrect module resolution.
When you use --format=esm and your package.json does not have "type": "module", Node.js will treat .js files as CommonJS by default. If you then try to import an ESM-generated file into a CJS context without proper handling (like using import() dynamically or a transpilation step), you’ll likely see errors related to require or module.exports not being defined in the ESM code, or import not being recognized. Conversely, trying to use CJS syntax in a pure ESM environment can also lead to errors.
The --platform flag in esbuild is crucial here. If you set --platform=node and --format=esm, esbuild will generate ESM that’s compatible with Node.js’s ESM implementation. If you set --platform=browser and --format=esm, it will generate standard browser ESM. For --format=cjs or --format=iife, the platform is less critical for the module syntax itself but still influences other optimizations.
When targeting the browser with --format=iife, esbuild will typically expose your bundle’s exports on a global variable. By default, this variable’s name is derived from the output filename (e.g., dist for dist/index.iife.js). You can explicitly control this with the --global-name flag, like --global-name=myLibrary. This is how libraries like jQuery or Lodash used to be distributed to be included via <script> tags.
The most surprising thing about --format=esm is its interaction with older Node.js versions. While ESM is the standard, Node.js’s implementation has evolved. If you’re building for a Node.js environment and choose --format=esm, esbuild generates code that relies on Node.js’s native ESM loader. This means require() won’t work directly within your bundled ESM output, and you’ll need to use import() or ensure your entire project is set up for ESM (e.g., with "type": "module" in package.json or using .mjs files).
The next hurdle is often managing dependencies when bundling for different formats.