How to Configure Node.js for Native ESM Support
Step-by-step guide to enabling native ESM in Node.js: set type=module, polyfill __dirname with import.meta.url, and bridge CJS dependencies with createRequire.
Node.js defaults to CommonJS parsing for every .js file unless you tell it otherwise, so dropping an import statement into a plain .js file raises SyntaxError: Cannot use import statement in a module at startup — before a single line of your code runs. Switching to native ESM requires three coordinated changes: declaring the package boundary in package.json, replacing CJS path globals that ESM removes, and deciding how to load any dependencies that have not yet migrated. This page walks through each change with exact before/after diffs and the verification commands that confirm you are done.
Root Cause Explanation
Node.js uses a single field — "type" in package.json — to decide whether .js files are parsed as ESM or CJS. When "type" is absent or set to "commonjs", Node.js wraps every .js file in a CJS module wrapper that injects require, module, exports, __filename, and __dirname. The moment you set "type": "module", that wrapper disappears: import/export syntax is valid, but require, __dirname, and __filename are gone. Any code that relies on them throws a ReferenceError at runtime.
The .mjs and .cjs extensions bypass the type field entirely — .mjs is always ESM and .cjs is always CJS — so they are safe to use in mixed codebases, but they require every import path to use the explicit extension, which adds friction during refactoring.
Minimal Reproduction
The following minimal project is enough to trigger all three common errors at once.
my-lib/
├── package.json ← missing "type": "module"
└── src/
└── index.js ← uses import + __dirname
{
"name": "my-lib",
"version": "1.0.0"
}
// src/index.js
import { readFileSync } from 'fs';
// ReferenceError: __dirname is not defined
const data = readFileSync(`${__dirname}/data.json`, 'utf8');
Running node src/index.js produces:
SyntaxError: Cannot use import statement in a module
at wrapSafe (internal/modules/cjs/loader.js:915:16)
Because Node.js is parsing index.js as CJS, the import keyword is a syntax error before the __dirname issue even surfaces.
Step-by-Step Fix
Step 1 — Declare the ESM package boundary
Add "type": "module" to the root package.json. If a "main" field points to a .cjs file, rename it to use the .cjs extension explicitly, or replace "main" with an exports field that maps conditions properly.
Before:
{
"name": "my-lib",
"version": "1.0.0",
"main": "index.js"
}
After:
{
"name": "my-lib",
"version": "1.0.0",
"type": "module",
"exports": {
"import": "./dist/index.js",
"default": "./dist/index.js"
}
}
HAZARD PREVENTION: Do not add
"type": "module"while leaving"main"pointing at a.jsfile that other packages load viarequire(). Any CJS caller will receiveERR_REQUIRE_ESM. Either add a.cjsbuild and wire it under the"require"condition inexports, or communicate the breaking change with a major version bump.
Step 2 — Replace __dirname and __filename
ESM exposes import.meta.url instead of a filename string. Convert it to a path using two Node.js built-ins — no npm package needed.
Before:
// CJS — unavailable in ESM
const config = require('path').join(__dirname, '../config.json');
After (TypeScript):
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const config = join(__dirname, '../config.json');
import.meta.url always returns a file:// URL string such as file:///home/user/my-lib/src/index.js. Passing it directly to join() or string interpolation produces incorrect paths on Windows because the drive letter (C:) follows the triple slash. fileURLToPath() handles the conversion correctly on all platforms.
Step 3 — Bridge legacy CJS dependencies with createRequire
When a dependency you consume has not migrated to ESM and ships no "exports" field, dynamic import() will often still work, but if you need synchronous loading or need to reach into a CJS module’s internal paths, use module.createRequire.
Before:
// Fails in ESM with: ReferenceError: require is not defined in ES module scope
const legacy = require('some-cjs-only-package');
After (TypeScript):
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const legacy = require('some-cjs-only-package');
createRequire creates a CJS resolver anchored to the current file’s directory, so relative paths resolve correctly. The resulting require function is file-local — do not export it, and do not share it across module boundaries, because doing so leaks CJS semantics into modules that should be pure ESM.
HAZARD PREVENTION:
createRequirebypasses theexportsfield of any package it loads. If a package usesexportsto gate internal paths,require('pkg/internal/path')viacreateRequiremay still work even whenimport 'pkg/internal/path'would throwERR_PACKAGE_PATH_NOT_EXPORTED. This is a footgun — preferimport()for all new code and usecreateRequireonly as a temporary bridge.
Step 4 — Add explicit file extensions to all imports
ESM in Node.js does not perform automatic extension resolution. Every relative import must include its extension.
Before:
import { helper } from './utils'; // ERR_MODULE_NOT_FOUND
import { config } from './config/index'; // ERR_UNSUPPORTED_DIR_IMPORT
After:
import { helper } from './utils.js';
import { config } from './config/index.js';
Note that TypeScript source files use .ts extensions on disk but the compiled output uses .js. When writing TypeScript that targets ESM, write the import with a .js extension even though the source file is .ts — the TypeScript compiler rewrites it correctly during compilation.
Visualisation: Node.js module parse decision
The diagram below shows how Node.js selects a parse mode for each file it loads.
Verification Command
Run the following to confirm ESM is active and the three common errors are gone:
node --input-type=module --eval "
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
console.log('ESM active, __dirname polyfill:', __dirname);
"
Expected output (path will vary):
ESM active, __dirname polyfill: /home/user/my-lib
For a package you have already built, run publint to catch misconfigured type fields and missing extension hints before you publish:
npx publint
A passing run looks like:
✔ No issues found.
If publint reports "type":"module" is set, but pkg.main uses a CJS extension", revisit Step 1 and switch to an exports field.
Edge Cases / Gotchas
-
pnpm vs npm symlinks. pnpm uses a content-addressable store with symlinks. If your package’s
package.jsonis resolved through a symlink,import.meta.urlreflects the real path, not the symlink path. Keep all path construction relative toimport.meta.urlrather thanprocess.cwd()to stay symlink-safe. -
TypeScript
moduleResolution: "node16"or"bundler". When targeting native ESM output from TypeScript, set"module": "NodeNext"and"moduleResolution": "NodeNext"intsconfig.json. TypeScript will then enforce.jsextensions on relative imports and validateexportsfield conditions correctly. -
Vite vs Node.js
conditionNames. Vite’s dev server adds the"browser"condition before"import", which can cause it to resolve a different entry than Node.js does. If you maintain separate browser and Node.js builds, always list"browser"before"import"in yourexportsmap so bundlers and Node.js agree on priority. -
Yarn PnP. Yarn Plug’n’Play patches the Node.js module resolver.
createRequirestill works in PnP mode, but you must resolve paths through the PnP API rather than rawfscalls. Test withyarn noderather than barenodewhen using PnP. -
--experimental-vm-modulesfor Jest. Jest does not natively support ESM. You must pass--experimental-vm-modulesto Node.js when running Jest against an ESM package:NODE_OPTIONS=--experimental-vm-modules jest. -
Dynamic
import()in CJS callers. If a CJS file callsimport('your-esm-package'), the result is a Promise. This is the correct interop path — do not attempt to unwrap the Promise synchronously, as it will always resolve toundefinedin that context.
FAQ
Why do I still get ERR_REQUIRE_ESM after adding "type": "module"?
A caller — likely a test runner, a CLI tool, or another package in the same monorepo — is still loading your entry point with require(). Adding "type": "module" makes your .js files ESM, but it cannot change how callers invoke them. Either give them a "require" condition in your exports field pointing at a .cjs build, or update the caller to use import().
Can I use __dirname in an ESM file?
Not directly. The CJS wrapper that injected __dirname does not run for ESM files. Reconstruct it with dirname(fileURLToPath(import.meta.url)) from Node.js built-ins — no npm package is needed, and the result is identical on POSIX and Windows.
Does "type": "module" affect .cjs files?
No. The .cjs extension pins a file to CommonJS parsing regardless of the type field. Conversely, .mjs pins a file to ESM. The type field only governs plain .js files.
What happens to top-level await when I enable ESM?
Top-level await is available in ESM without any extra flag from Node.js 14.8 onward. It does not exist in CommonJS. Migrating to ESM is therefore a prerequisite for any module that needs to await at the top level (for example, during module initialisation).
Does NODE_OPTIONS=--input-type=module affect child processes?
Yes — NODE_OPTIONS is inherited by every child process Node.js spawns. Prefix it on a single command (NODE_OPTIONS=… node script.js) rather than exporting it into your shell environment, to avoid changing the parse mode of unrelated scripts.
Related
- Understanding ESM vs CJS Module Formats — the parent cluster covering why two module systems coexist in Node.js and how the runtime chooses between them.
- Resolving
type: moduleConflicts in Legacy Projects — addresses the sameERR_REQUIRE_ESMscenario from the perspective of a project that cannot yet migrate all callers. - Fixing
require()Errors in Pure ESM Packages — the complementary fix when you are the consumer of a pure-ESM package and your toolchain still usesrequire().