Back to core workflows Fix dependency resolution Tune package metadata Jump to monorepo patterns

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.

Why named imports fail for CommonJS packages The ESM loader runs cjs-module-lexer over a CommonJS module; static exports become named bindings while dynamic ones are only reachable through the default export. CJS module module.exports = ... cjs-module-lexer static scan only named bindings detected exports static default only dynamic exports dynamic
Only statically detectable exports become named imports; everything else is reachable only through the default export.

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 .mjs file; if the lexer cannot see the export, the test fails and you catch it before release.
  • Prefer shipping a native ESM build (an import condition) 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, since cjs-module-lexer detection 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