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:

Exact Symptom Node.js fails at link time, before any of your code runs: Exact Symptom Node.js fails at link time, before any of your code runs:
Exact Symptom — the core idea of this section at a glance.
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.

The 'named export not found' error fires when ESM code destructures named bindings from a CommonJS module that Node only exposes as a single default object. Node's ESM loader statically analyzes a CommonJS module to detect its named exports using cjs-module-lexer, and when the exports are created in a way the lexer cannot detect — assigned dynamically, or built at runtime — Node cannot expose them as named imports, so import { thing } from 'cjs-pkg' fails even though thing exists on the module's exports at runtime.

The mismatch is between how the CommonJS module defines its exports and what static analysis can see. A module that does module.exports = { thing } at the top level is usually detectable; one that assigns exports conditionally, in a loop, or through a helper often is not. The runtime value is fine — require('cjs-pkg').thing works — but the ESM named-import syntax depends on static detection that the module's export style defeated.

Resolution Steps

1. Default-import, then destructure

Resolution Steps The universally correct fix is the one Node prints in the error: import the default (the entire module.exports) and dest Resolution Steps 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.
Resolution Steps — the core idea of this section at a glance.

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.

Import the default and destructure from it, which always works:

// Instead of: import { thing } from 'cjs-pkg';
import cjsPkg from 'cjs-pkg';
const { thing } = cjsPkg;
// Or use createRequire for a CommonJS-style load
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const { thing } = require('cjs-pkg');

The default import receives the whole module.exports object, so destructuring from it accesses the same values the named import could not resolve. For a package you control, adding a real ESM build with static named exports is the long-term fix.

Validation

Verify each form resolves before wiring it into application code:

Validation Verify each form resolves before wiring it into application code: Validation Verify each form resolves before wiring it into application code:
Validation — the core idea of this section at a glance.
# 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.
Prevention & CI Guardrails Prevention & CI Guardrails in production JavaScript package workflows. Prevention & CI Guardrails Prevention & CI Guardrails in production JavaScript package workflows.
Prevention & CI Guardrails — the core idea of this section at a glance.
  • Import the default and destructure from it when a CommonJS package's named exports are not statically detectable.
  • For your own packages, ship a real ESM build with static export statements.
  • Avoid dynamic or conditional export assignment in CommonJS modules meant to be imported from ESM.
  • Test named imports from ESM in CI so a detection failure surfaces before a consumer hits it.

Why static analysis can't always see the exports

Node's ESM loader has to know a CommonJS module's named exports before the module runs, because ESM imports are resolved statically — the bindings must exist at link time, before any code executes. To provide named imports from CommonJS, Node uses cjs-module-lexer to scan the module's source and detect assignments to module.exports and exports. This works for the common, static patterns but cannot detect exports created dynamically, because detecting those would require running the module, which defeats the point of static resolution.

Static detection The lexer scans source but can't run it. ESM needs static exports before run cjs-module-lexer scans detects static only dynamic exports only via default
Named imports need statically-detectable exports; dynamic ones only the default exposes.

This is why a module that assigns its exports in a loop, through a helper function, or conditionally often cannot be named-imported even though the exports are perfectly real at runtime. The lexer sees code it cannot statically resolve to specific export names, so it exposes only the default. The error is not that the export is missing — it exists on the runtime object — but that the static analysis could not enumerate it in advance. Understanding this explains why the default-import workaround always works: the default is the whole runtime module.exports object, so destructuring from it accesses the dynamically-created exports the static named import could not see. It also explains the durable fix for your own packages — a real ESM build with literal export statements gives the loader static exports to resolve directly, sidestepping the lexer's limits entirely.

The durable fix: shipping a real ESM build

The default-import workaround resolves the error for a consumer, but for a package you control, the durable fix is to ship a genuine ESM build with static named exports so consumers can import your named bindings directly. An ESM build uses literal export statements that Node's loader resolves without any lexing guesswork, so import { thing } from 'your-pkg' works because the export is statically present, not inferred from CommonJS source.

CJS-only vs dual A CommonJS-only package versus a real ESM build. CommonJS only • exports inferred by lexer • dynamic ones invisible • named import may fail Real ESM build • literal export statements • statically resolved • named imports work
A real ESM build gives static named exports, removing the detection limit.

This is part of the broader case for dual-format publishing: a package that ships both an ESM and a CommonJS build lets ESM consumers use native named imports while CommonJS consumers use require, with each build providing exports in the form its consumers expect. The exports map routes each consumer to the right build. Shipping only CommonJS and relying on Node's interop is what exposes consumers to the named-export detection limits in the first place; shipping a real ESM build removes that exposure. For a maintained package, the small additional build complexity of emitting ESM is worth eliminating a class of consumer-facing import errors that the CommonJS-only approach cannot fully avoid.

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.

Why does import { x } from 'pkg' fail when pkg.x exists?

The package is CommonJS and Node's static analysis (cjs-module-lexer) could not detect x as a named export — usually because it is assigned dynamically or conditionally. The export is real at runtime but not statically visible. Import the default and destructure: import pkg from 'pkg'; const { x } = pkg;.

Why can Node see some named exports but not others?

The lexer detects static, top-level assignments to module.exports/exports but not dynamic ones (in a loop, via a helper, conditional). It exposes the ones it can enumerate and omits the rest, so a mix of static and dynamic exports produces a partial set of named imports.

How do I fix this for a package I publish?

Ship a real ESM build with literal export statements, ideally alongside a CommonJS build wired through the exports map. Static exports resolve directly without the lexer's guesswork, so ESM consumers can use native named imports.

Does this error happen with ESM packages too?

No — a real ESM package uses static export statements that Node resolves directly, so named imports work. The error is specific to importing named bindings from a CommonJS package whose exports the static lexer could not detect. Import the default and destructure, or use a package's ESM build.

Related

ESM and CJS Interoperability