Bun Throws ERR_REQUIRE_ESM When a CommonJS File Loads an ESM Package
A CommonJS file fails under Bun when it require()s an ESM-only package, and the runtime throws Error [ERR_REQUIRE_ESM]: require() of ES Module not supported.
What the error means
ERR_REQUIRE_ESM means the module loader reached a package entry point that is classified as ES module code, but the caller used CommonJS require().
That boundary is not about Bun specifically. It is the same format mismatch that Node.js enforces for mixed module graphs. Bun aims for Node compatibility here, so the failure happens when Bun has enough information to classify the target as ESM and the call site is still CommonJS.
The important detail is that require() is synchronous and expects CommonJS semantics. ES modules use static import and asynchronous module loading. A CommonJS loader cannot safely execute an ES module in the same synchronous path, so the runtime refuses with ERR_REQUIRE_ESM.
A minimal reproduction looks like this:
ts// src/index.cjs const chalk = require("chalk") console.log(chalk.green("hello"))
Run it with Bun:
bashbun src/index.cjs
If the installed chalk version is ESM-only, such as chalk@5, the output includes:
textError [ERR_REQUIRE_ESM]: require() of ES Module not supported
Why CommonJS cannot require() an ES module
CommonJS and ES modules are different loading systems.
CommonJS:
- uses
require() - loads synchronously
- exports with
module.exportsandexports - resolves files using CommonJS rules
ES modules:
- use
importandexport - may be loaded asynchronously
- have live bindings
- are classified by file extension,
package.jsonmetadata, or the export target chosen by the resolver
The loader boundary is where this fails. A CommonJS file can import some ESM through a compatibility layer only if the runtime can switch into the ESM loader path. A plain require() call cannot do that. It stays on the CommonJS path and stops when the target is tagged as ESM-only.
That tag can come from several places:
- the package’s
package.jsoncontains"type": "module" - the file extension is
.mjs - the package
exportsmap pointsimportandrequireto different files, and therequiretarget is missing or still ESM - the package is published as ESM-only, with no CommonJS entry point
How Bun decides which loader to use
Bun follows Node compatibility rules for module format detection.
The main signals are:
package.json"type"- file extension
exportsconditions- the import site format
A file inside a package with "type": "module" is treated as ESM by default when it has a .js extension. A .mjs file is also ESM regardless of type. A .cjs file is CommonJS regardless of type.
Examples:
json{ "type": "module" }
With that package.json:
src/index.jsis ESMsrc/index.mjsis ESMsrc/index.cjsis CommonJS
Without that package.json:
src/index.jsis CommonJSsrc/index.mjsis ESMsrc/index.cjsis CommonJS
This matters because the same code can be valid in one file and invalid in another. If a CommonJS file uses require() against a package entry point that Bun has classified as ESM, the error is expected.
Package exports can redirect the target
The package export map is often the reason the resolver lands on an ESM file even when the package contains both formats.
A package can ship separate CommonJS and ESM entry points:
json{ "name": "example-pkg", "exports": { ".": { "import": "./dist/index.js", "require": "./dist/index.cjs" } } }
This is the preferred compatibility pattern.
If the map is written incorrectly, require() may still be sent to ESM:
json{ "name": "example-pkg", "exports": { ".": "./dist/index.js" }, "type": "module" }
In that case, both import "example-pkg" and require("example-pkg") can resolve to an ESM file, because the export target is just ./dist/index.js and the package type marks .js as ESM. require() then fails with ERR_REQUIRE_ESM.
This is why the presence of exports matters as much as the file extension. The resolver does not just look at the specifier. It follows the package map and the package metadata to determine the actual module format.
A concrete example with chalk
chalk@5 is ESM-only. It does not provide a CommonJS entry point.
Install it:
bashbun add chalk@5
CommonJS usage fails:
ts// src/index.cjs const chalk = require("chalk") console.log(chalk.green("ok"))
bashbun src/index.cjs
The fix is to use ESM syntax in an ESM file:
ts// src/index.mts import chalk from "chalk" console.log(chalk.green("ok"))
Or, if the file is .ts and the package type is module, the same import syntax works:
ts// src/index.ts import chalk from "chalk" console.log(chalk.green("ok"))
If the surrounding project is still CommonJS, then a dynamic import() is the bridge:
ts// src/index.cjs async function main() { const chalk = await import("chalk") console.log(chalk.default.green("ok")) } main().catch((err) => { console.error(err) process.exitCode = 1 })
That works because import() switches to the ESM loader path and returns a promise. The CommonJS file stays CommonJS, but the actual package loads through the ESM mechanism.
Dynamic import() in CommonJS
import() is the correct escape hatch when a CommonJS file must reach an ESM-only package.
Use it when:
- the file must remain
.cjs - the rest of the codebase is still CommonJS
- the dependency only ships ESM
Example:
ts// src/render.cjs async function render() { const { default: yaml } = await import("yaml") const data = yaml.parse("name: bun") console.log(data.name) } render().catch((err) => { console.error(err) process.exitCode = 1 })
The default access depends on how the ESM package exports its API. Some ESM packages expose a default export, others expose named exports. You need to match the package’s actual export shape.
This is the key difference from require(). require() expects one synchronous export object. import() can load an ES module and expose the module namespace.
File extensions that prevent the mismatch
The file extension is often the simplest fix because it moves the caller into the correct module system.
Use .mjs for ESM:
ts// src/app.mjs import { readFile } from "node:fs/promises" import pkg from "nanoid" console.log(pkg.nanoid())
Use .cjs for CommonJS:
ts// src/app.cjs const { readFile } = require("node:fs/promises") console.log(typeof readFile)
A .js file inherits its meaning from the nearest package.json "type" field.
That means these combinations are common:
- package
"type": "module"plus.jsandimport - package
"type": "commonjs"or notypeplus.cjsandrequire - package
"type": "module"plus.cjsandrequirefor legacy CommonJS islands
If a package boundary or app boundary is mixed, naming the file extension clearly often prevents accidental loader mismatch.
How to check the boundary in practice
When ERR_REQUIRE_ESM appears, inspect these three places.
1. The caller file format
Check whether the calling file is CommonJS or ESM.
.cjsis CommonJS.mjsis ESM.jsdepends onpackage.json"type"
2. The dependency entry point
Inspect the dependency package:
bashcat node_modules/chalk/package.json
Look for:
"type": "module""exports""main"
For ESM-only packages, the exports map often points to ESM files and omits CommonJS paths entirely.
3. The exact import target
A package specifier like require("pkg") can resolve differently from require("pkg/subpath"). Subpaths are governed by the exports map. If the subpath is mapped only to ESM, require() fails even if the package root has a CommonJS build.
For example:
json{ "exports": { ".": { "import": "./dist/index.js", "require": "./dist/index.cjs" }, "./feature": "./dist/feature.js" }, "type": "module" }
Here require("example-pkg") works because require has a CJS target, but require("example-pkg/feature") fails because ./dist/feature.js is ESM under "type": "module" and no CommonJS alternative exists.
When Bun follows Node compatibility rules
Bun follows Node-compatible module classification in these cases:
- interpreting
.mjsas ESM - interpreting
.cjsas CommonJS - using
package.json"type"to classify.js - honoring
exportscondition resolution - rejecting
require()against ESM-only modules
That means the same source layout that fails under Node also fails under Bun. The error is not a Bun-specific parser issue. It is a loader boundary issue.
This is useful for debugging because the fix is usually structural, not runtime-specific. If the package is ESM-only, changing the runtime does not make synchronous require() valid.
When the export map changes the resolution target
The package export map can override what looks like a straightforward path.
A package without exports may fall back to main or legacy resolution rules. A package with exports is more explicit. The resolver only sees the paths the map allows.
That means this package can behave differently depending on its metadata:
json{ "main": "./dist/index.cjs" }
versus:
json{ "exports": { ".": "./dist/index.js" }, "type": "module" }
In the first case, require("example-pkg") may work if main points to CommonJS. In the second case, require("example-pkg") reaches an ESM file and fails.
Subpath exports make this sharper. A package can support require("pkg") but not require("pkg/subpath") if only the root export has a CommonJS condition.
Fix options, ordered by reliability
The cleanest fix is to make the import syntax match the package format.
Prefer ESM for ESM-only dependencies
If the dependency is ESM-only, use import from an ESM file.
ts// src/index.mts import express from "express"
This avoids wrappers and preserves normal static analysis.
Use dynamic import() from CommonJS when you must keep CommonJS
ts// src/index.cjs async function main() { const { default: express } = await import("express") }
This is the correct bridge when the surrounding code cannot move to ESM yet.
Switch to a CommonJS-compatible package version
Some packages have a last CommonJS major version. For example:
chalk@4is CommonJS-compatiblechalk@5is ESM-only
If the codebase is intentionally CommonJS, pinning to the CommonJS line can be the simplest operational choice.
bashbun add chalk@4
This is a compatibility choice, not a format fix. It works only when an older CommonJS version exists and is acceptable.
Publish dual entry points if you own the package
For package authors, ship both formats through exports:
json{ "name": "example-pkg", "type": "module", "exports": { ".": { "import": "./dist/index.js", "require": "./dist/index.cjs" } } }
This gives both loaders a valid target and prevents ERR_REQUIRE_ESM at the package boundary.
Keeping the error from returning
The durable fix is to keep module format aligned across the file, the package, and the entry point.
Use these checks:
- if the dependency is ESM-only, do not
require()it - if the current file is CommonJS, use
import()or convert the file to ESM - if the package has
exports, verify therequirecondition points to a CommonJS file - if
package.jsonhas"type": "module", do not assume.jsis CommonJS - use
.cjsfor CommonJS islands and.mjsfor ESM boundaries
The practical default is simple: prefer converting the caller to ESM when the dependency is already ESM-only. Use dynamic import() only when the caller must remain CommonJS. That keeps the import path and package format aligned, which is what prevents ERR_REQUIRE_ESM from appearing again.