Node.js require is not defined in ES Module Scope: How to Fix ESM/CJS Interop

You run your Node.js script and get this:

ReferenceError: require is not defined in ES module scope, you can use import instead

Or this close cousin:

Error [ERR_REQUIRE_ESM]: require() of ES Module /path/to/module.js not supported.
Instead change the require of /path/to/module.js to a dynamic import() which is available in all CommonJS modules.

Here’s what’s actually wrong: Node.js is treating your file as an ES Module, so require no longer exists in scope. The fix depends on why you need require and what your file is trying to do. I hit this on a Node.js 20 backend after a teammate added "type": "module" to package.json without migrating the existing require() calls. Four approaches below — pick the one that fits your situation.

Why require is not defined in ES module scope Happens

Node.js uses three signals to decide whether a file is ESM or CommonJS:

  • "type": "module" in package.json — the most common trigger. Every .js file in that package becomes an ES Module. require is a CJS global; it doesn’t exist in ESM scope.
  • .mjs file extension — always treated as ESM, regardless of package.json.
  • import or export at the top level — if the loader resolves the file as ESM, you’re in ESM scope.

When "type": "module" is set, Node.js also removes __dirname, __filename, and module.exports from scope — not just require. That catches a lot of people off guard.

The error doesn’t always surface in your own code either. I’ve seen it fire inside a postinstall script from a dependency that wasn’t updated to handle ESM environments, which sent me hunting in completely the wrong place for 45 minutes.

If you’re not sure which mode Node.js is treating your file as, add this temporary line at the top:

console.log(typeof require !== 'undefined' ? 'CJS' : 'ESM');

That tells you immediately what the loader resolved, which saves a lot of guesswork when the project has a mix of .js, .mjs, and .cjs files.

The Wrong Path: Removing "type": "module" Doesn’t Always Work

The obvious first move is to delete "type": "module" from package.json. That restores require — until you hit the reason it was added in the first place:

SyntaxError: await is only valid in async functions and the top level bodies of modules

Top-level await only works in ES Modules. If any file in the project uses it, you can’t just revert to CJS wholesale.

The other wrong turn is using import for everything without understanding the asymmetry: ESM can import CJS, but CJS cannot require() ESM (at least, not without workarounds — Node.js 23 changed this, see the gotchas section). If you have a mixed codebase, blanket-converting to ESM breaks any CJS file that tries to require() a module you’ve just converted.

Fix 1: Replace require() with import Statements

If you want to fully commit to ESM, convert require() calls to static import statements.

Static imports:

// Before (CJS)
const express = require('express');
const { readFile } = require('fs/promises');
// After (ESM)
import express from 'express';
import { readFile } from 'fs/promises';

Replacing __dirname and __filename (these don’t exist in ESM):

import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

Why this works: import.meta.url is the canonical URL of the current module. fileURLToPath converts it to a filesystem path — the exact equivalent of the old __filename.

JSON files need an import attribute (the syntax changed between Node versions):

// Node.js 18-20 (assert syntax)
import config from './config.json' assert { type: 'json' };
// Node.js 21+ (with syntax -- assert is deprecated)
import config from './config.json' with { type: 'json' };

This is the cleanest fix if you’re starting fresh or doing a full migration, but it’s invasive on a large existing codebase.

Fix 2: Use createRequire() to Keep require in an ESM File

Sometimes you genuinely need require() inside an ES Module — for a package that only ships CJS, for JSON loading without the assert/with syntax, or for dynamic plugin loading by file path. Use createRequire:

import { createRequire } from 'module';
const require = createRequire(import.meta.url);
// Now require() works exactly like it does in CJS
const legacyLib = require('some-cjs-only-package');
const config = require('./config.json');
const plugin = require(./plugins/${pluginName}.js);

Why this works: createRequire constructs a CJS-compatible require function bound to a base URL. Passing import.meta.url anchors it to your module’s location so relative paths resolve correctly.

This is my go-to when working with packages that haven’t shipped an ESM build — which is still a surprisingly large chunk of the npm ecosystem. It’s surgical: you’re not converting the whole file, just creating a CJS bridge for the specific calls that need it.

Note that the require you create this way is a normal variable — there’s nothing special about the name. You could call it cjsRequire if you want to make the intent clearer to future readers.

One thing createRequire cannot help with: you still can’t synchronously load a module that uses top-level await. That module is async by definition, and the synchronous CJS loader has no mechanism to block on a Promise. For those, dynamic import() is the only path forward.

Fix 3: Use Dynamic import() for Lazy or Conditional Loads

Dynamic import() works in both CJS and ESM files and returns a Promise. It’s the right tool when you want to load a module conditionally or in a CJS context that needs to consume an ESM module:

// Works in .cjs or CJS-mode .js files
async function loadEsmOnlyModule() {
const { someExport } = await import('./esm-only-module.mjs');
return someExport;
}
// Conditional loading
const utils = process.env.USE_FAST
? await import('./fast-utils.mjs')
: await import('./safe-utils.mjs');

The catch: import() is async. If your CJS entry point isn’t in an async context, you’ll need to wrap the call or restructure. A common pattern is a top-level async IIFE in your entry file:

// entry.cjs
(async () => {
const { run } = await import('./main.mjs');
await run();
})();

Fix 4: Rename Files to .cjs to Opt Out of ESM

The most minimal surgical fix: rename specific files from .js to .cjs. Node.js always treats .cjs as CommonJS regardless of "type": "module" in package.json.

mv src/utils.js src/utils.cjs
mv src/config.js src/config.cjs

Then update any import paths in your ESM files:

// ESM file importing a .cjs module -- this works
import legacyUtils from './utils.cjs';

This works because ESM can import CJS modules — the module.exports object is exposed as the default export. The problematic direction is the reverse: CJS require()-ing an ESM module.

This approach is useful when you have a few files that are deeply CJS-dependent and migrating them isn’t worth the effort right now.

Verification

After any fix, confirm the error is gone:

node --version # confirm Node.js 18+ for solid ESM support
node src/your-file.js # should no longer throw ReferenceError

If you used createRequire, add a quick sanity check:

console.log(typeof require); // "function" -- createRequire is working

For TypeScript projects, also run:

npx tsc --noEmit

TypeScript has its own module resolution layer that can diverge from Node.js’s runtime behavior. The two need to agree on whether files are ESM or CJS — mismatches between tsconfig.json and package.json are a common source of errors that only show up at runtime, not at compile time.

Edge Cases and Gotchas

Node.js 23 changed the rules. Starting in Node.js 23 (and experimentally in 22.x with --experimental-require-module), require(esm) is enabled by default. CJS files can now require() ESM modules as long as they don’t use top-level await. If they do, you’ll get ERR_REQUIRE_ASYNC_MODULE instead of ERR_REQUIRE_ESM. Don’t assume Node.js 23 behavior in LTS environments — Node.js 20 LTS still throws ERR_REQUIRE_ESM for this.

TypeScript / ts-node configuration. You need "module": "NodeNext" and "moduleResolution": "NodeNext" in tsconfig.json for proper ESM support. Using "module": "CommonJS" causes ts-node to emit CJS even if your source uses import/export, masking the real problem. For Jest, you need NODE_OPTIONS='--experimental-vm-modules' and a Jest config that sets extensionsToTreatAsEsm.

Named export inference from CJS default. When ESM imports a CJS module, Node.js exposes module.exports as the default export and tries to infer named exports. This works for simple objects but fails for factories, dynamic exports, or getters. If import { foo } from 'some-cjs-pkg' gives you undefined, fall back to import pkg from 'some-cjs-pkg'; const { foo } = pkg;.

The exports field in package.json overrides file paths. If a package defines an exports map, Node.js enforces those entry points strictly. Deep-linking into dist/ or lib/ paths directly will throw ERR_PACKAGE_PATH_NOT_EXPORTED, even if the file exists at that path. This often compounds the ESM error — check the package’s package.json for an exports field and use only the documented entry points.

Top-level await blocks createRequire in Node.js 23. Even with the new require(esm) support, if your ESM module uses top-level await, it cannot be synchronously require()d. The runtime throws ERR_REQUIRE_ASYNC_MODULE. There’s no workaround — async modules must be loaded via import() or top-level await in another ESM module.


TL;DR fix: Add import { createRequire } from 'module'; const require = createRequire(import.meta.url); at the top of your ESM file to restore require — or rename any CJS-dependent files from .js to .cjs to opt out of ESM loading entirely.

Leave a Comment