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 .js file that other packages load via require(). Any CJS caller will receive ERR_REQUIRE_ESM. Either add a .cjs build and wire it under the "require" condition in exports, 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: createRequire bypasses the exports field of any package it loads. If a package uses exports to gate internal paths, require('pkg/internal/path') via createRequire may still work even when import 'pkg/internal/path' would throw ERR_PACKAGE_PATH_NOT_EXPORTED. This is a footgun — prefer import() for all new code and use createRequire only 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.

Node.js module parse mode decision tree Decision flow: Node.js checks the file extension first (.mjs → ESM, .cjs → CJS), then walks up to find package.json and reads the type field (module → ESM, otherwise CJS). Node.js loads a file extension is .mjs or .cjs? .mjs ESM parse .cjs CJS parse .js package.json "type" field? "module" ESM parse absent / "commonjs" CJS parse Node.js parse-mode decision — extension wins, then package.json "type"

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.json is resolved through a symlink, import.meta.url reflects the real path, not the symlink path. Keep all path construction relative to import.meta.url rather than process.cwd() to stay symlink-safe.

  • TypeScript moduleResolution: "node16" or "bundler". When targeting native ESM output from TypeScript, set "module": "NodeNext" and "moduleResolution": "NodeNext" in tsconfig.json. TypeScript will then enforce .js extensions on relative imports and validate exports field 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 your exports map so bundlers and Node.js agree on priority.

  • Yarn PnP. Yarn Plug’n’Play patches the Node.js module resolver. createRequire still works in PnP mode, but you must resolve paths through the PnP API rather than raw fs calls. Test with yarn node rather than bare node when using PnP.

  • --experimental-vm-modules for Jest. Jest does not natively support ESM. You must pass --experimental-vm-modules to Node.js when running Jest against an ESM package: NODE_OPTIONS=--experimental-vm-modules jest.

  • Dynamic import() in CJS callers. If a CJS file calls import('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 to undefined in 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.



↑ Back to Understanding ESM vs CJS Module Formats