Resolving 'Named Export Not Found' in ESM
When you import a CommonJS package into an ES module and ask for a named binding, Node.js often refuses with Named export 'x' not found. The package works fine — the problem is that the ESM loader cannot statically see CommonJS named exports. This page explains why, and gives the exact import shapes that work.
Exact Symptom
Node.js fails at link time, before any of your code runs:
import { render } from 'legacy-cjs-pkg';
^^^^^^
SyntaxError: Named export 'render' not found. The requested module 'legacy-cjs-pkg' 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-cjs-pkg';
const { render } = pkg;
at ModuleJob._instantiate (node:internal/modules/esm/module_job:...)
Node helpfully prints the working alternative inside the error itself. The failure is a SyntaxError thrown during instantiation, not a runtime TypeError, because ESM links its imports before execution.
Root Cause Analysis
ES modules use static imports: the loader resolves and links every named binding before any module body executes. To do that for a CommonJS dependency, Node runs a lightweight static analyzer called cjs-module-lexer over the CJS source to guess which named exports exist.
That lexer is intentionally limited. It can detect simple, statically-written patterns like exports.foo = ... and module.exports = { foo, bar } written literally. It cannot follow dynamic assignments such as exports built in a loop, copied via Object.assign, re-exported through a computed key, or returned from a factory. When it cannot prove a name exists, that name is simply not offered as a named export — only the default export (the whole module.exports object) is guaranteed.
So import { render } from 'legacy-cjs-pkg' fails even though require('legacy-cjs-pkg').render works perfectly, because the lexer never surfaced render as a statically analyzable name. This is the inverse of the synchronous-load problem; both stem from the format boundary described in ESM and CJS Interoperability, and both are ultimately decided by how the package's entry resolves under the rules in Understanding package.json Fields.
Resolution Steps
1. Default-import, then destructure
The universally correct fix is the one Node prints in the error: import the default (the entire module.exports) and destructure the names you need at runtime.
// app.mjs
import pkg from 'legacy-cjs-pkg';
const { render, parse } = pkg;
render(parse('input'));
This works for any CommonJS package because the default export is always the full module.exports object. The destructuring happens at runtime, after the module body executes, so the lexer's static limitation is irrelevant.
2. Import the namespace when there is no clean default
Some CommonJS modules assign individual properties (exports.a = ...; exports.b = ...) without a single module.exports = {}. In transpiled-interop scenarios the default may be wrapped. A namespace import captures every property regardless of shape:
// app.mjs
import * as pkg from 'legacy-cjs-pkg';
const render = pkg.render ?? pkg.default?.render;
The pkg.default?.render fallback handles the case where an interop layer nested the real exports under default.
3. Confirm the package's module.exports shape
Decide between the two patterns above by inspecting what the package actually exports at runtime. If module.exports is a single object, the named keys live directly on the default import; if it is a function or class, the default is that callable:
// inspect.mjs
import pkg from 'legacy-cjs-pkg';
console.log(typeof pkg, Object.keys(pkg));
// "object" with keys -> destructure from default
// "function" -> the default itself is the export; call it directly
4. For your own packages: make named exports statically analyzable
If you publish a CommonJS package and want consumers to use named imports, write module.exports in a shape the lexer can read literally, or ship a real ESM build via a dual exports map:
// index.cjs — written so cjs-module-lexer can see the names
function render() {}
function parse() {}
module.exports = { render, parse };
{
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}
Shipping a true ESM artifact (the import condition) is the most robust option — native ESM exposes named bindings directly, no lexing required. This is exactly the dual-build covered in How to Configure package.json for Dual Modules.
Validation
Verify each form resolves before wiring it into application code:
# This is what fails — confirm the SyntaxError
node --input-type=module -e "import { render } from 'legacy-cjs-pkg'"
# The working default-import shape
node --input-type=module -e "import pkg from 'legacy-cjs-pkg'; const { render } = pkg; console.log(typeof render)"
# Enumerate what the loader actually exposes as named exports
node --input-type=module -e "import * as m from 'legacy-cjs-pkg'; console.log(Object.keys(m))"
If Object.keys(m) shows only ['default'], the lexer found nothing static — destructure from the default. If it lists the names, you may import them directly.
Prevention & CI Guardrails
- For consumed CommonJS packages, standardize on the default-import-then-destructure pattern in code review so contributors do not reintroduce broken named imports.
- For your own libraries, add a CI test that does
import { yourExport } from 'your-pkg'in an.mjsfile; if the lexer cannot see the export, the test fails and you catch it before release. - Prefer shipping a native ESM build (an
importcondition) over relying on the lexer to infer names from a CommonJS file. - Run a publint-style export check in CI to flag entry points whose declared interface will not survive ESM static analysis.
- Pin the Node.js version with
engines, sincecjs-module-lexerdetection improves across versions and you want consistent behavior between local and CI.
Frequently Asked Questions
Why does require() work but the named import fail for the same package?
require() returns the live module.exports object at runtime, so any property is reachable. The ESM loader instead resolves named bindings statically before execution, and cjs-module-lexer cannot detect dynamically created exports — so those names never exist as named imports.
Will adding "type": "module" to my project fix this?
No. The error is about the imported CommonJS package's exports, not your project's module type. Setting "type": "module" changes how your files parse, but the dependency is still CommonJS and still needs the default-import shape.
Is destructuring from the default import slower or less safe?
No. It is a normal runtime property access on the module.exports object — the same object require() would return. There is no measurable cost and no loss of functionality.
How do I let consumers use named imports from my CommonJS package?
Either write module.exports = { ... } in a literal, statically analyzable shape, or ship a real ESM build behind the import condition of your exports map. The ESM build exposes named bindings natively and bypasses the lexer entirely.
Related
- ESM and CJS Interoperability — the static-vs-dynamic export model behind this error.
- Fixing ERR_REQUIRE_ESM in Node.js — the sibling failure when you require an ESM package from CommonJS.
- Understanding package.json Fields — how entry resolution selects the file the loader analyzes.
- How to Configure package.json for Dual Modules — shipping a native ESM build so consumers get real named exports.