TypeScript

Fix: exports is not defined in ES module scope

How Node decides CommonJS vs ES module, and three remedies for aligning source syntax, compiler output and the runtime.

Mahdi Ben RhoumaMahdi Ben Rhouma
Updated
7 min read

TL;DR#

Node decides CommonJS vs ES module per file, using the nearest package.json, the extension and, for ambiguous files, the syntax. Once a package sets "type": "module", its plain .js files are parsed as ES modules. A .js file without an explicit type can also become ESM when Node detects module syntax. ES modules have no exports, module, or require in scope. A file that still writes exports.foo = … throws ReferenceError: exports is not defined in ES module scope the instant it runs; module.exports = … similarly fails because module is not defined. If the file contains only CommonJS syntax, rename it to .cjs (keeps the CommonJS syntax, Node always loads .cjs as CommonJS regardless of "type"), or by rewriting it with export/import syntax so it matches what "type": "module" expects. If it already mixes ESM statements and CommonJS assignments, convert the assignments to ESM; renaming such a mixed file to .cjs leaves invalid ESM statements in a CommonJS file. Pick the change that matches how the file is actually written.

The error#

text
ReferenceError: exports is not defined in ES module scope
This file is being treated as an ES module because it has a '.js' file extension and '\\?\C:\aotd-repro-exports-undefined\package.json' contains "type": "module". To treat it as a CommonJS script, rename it to use the '.cjs' file extension.
    at file:///C:/aotd-repro-exports-undefined/main.js:1:1
    at ModuleJob.run (node:internal/modules/esm/module_job:569:25)

This is the exact message from a minimal reproduction on Node.js 26.7.0: a package.json with "type": "module" next to a main.js that still opens with exports.hello = function hello() { … }. Node names the file and the exact package.json it blamed, which is the fastest way to confirm this diagnosis on your own project — the path in the second line of the error is the file Node actually read "type" from, not necessarily the one at your project root (a nested package.json wins over the one above it).

It is usually the second error in a pair. The original Stack Overflow question started from a NestJS project that imported a package shipped only as .mjs (@nuxt/vite-builder-edge). require()-ing that .mjs file first threw Error [ERR_REQUIRE_ESM]: require() of ES Module … not supported. Adding "type": "module" to package.json "fixed" that one — and immediately broke every file in the project that still used exports/module.exports (including TypeScript's own compiled output), which is where exports is not defined in ES module scope comes from.

Why it happens#

Node decides this per file, documented on the package.json and file extensions page: the nearest package.json's explicit "type" field controls how plain .js files in that package are parsed — "commonjs" means CommonJS; "module" means ES modules. A missing type is ambiguous: modern Node can detect module-only syntax, such as an export statement or top-level await, and load that file as ESM. The syntax detection documentation explains this case; omitting the field does not force CommonJS. Two extensions override the field outright, on the same page: a .mjs file is always an ES module and a .cjs file is always CommonJS, no matter what "type" says.

So once a project sets "type": "module", three things are simultaneously true, and the error comes from mixing them:

  • .js files are now parsed as ES modules — no require, exports, module, __dirname or __filename in scope.
  • .cjs files are still CommonJS, unconditionally.
  • A TypeScript project compiled with "module": "commonjs" in tsconfig.json still emits exports.foo = … and require(...) into its output .js files — tsc does not look at the runtime "type" field when choosing what to emit. The compiled file and the runtime disagree about what kind of module it is, and the runtime wins: Node parses the file as ESM because "type": "module" says so, hits the first exports. statement, and throws.

That third point is the one the NestJS case hit: the compiler kept emitting CommonJS syntax while the runtime had been told everything is now an ES module. Under "module": "commonjs", nothing about adding "type": "module" to package.json changes what the compiler emits — that is a second, independent setting, which is exactly the mismatch that throws. (Under "module": "node16"/"nodenext", tsc does look at the nearest "type" field per file and switches its emitted syntax to match — that newer setting is the fix in point 3 below, not the default most existing projects start from.)

Fix#

Pick the remedy that matches what actually produced the broken file.

1. The file is meant to stay CommonJS — rename it to .cjs#

If only one or two files in an otherwise-ESM project still use require/exports (a build script, a config file), renaming is the smallest change:

bash
mv main.js main.cjs

Reproduced: a file containing exports.hello = function hello() { return "hi"; }; under "type": "module" throws the exact error above when run as main.js, and prints hi with no error once renamed to main.cjs — nothing else in the file changed.

If the file is required elsewhere by a bare specifier, update that one import line too (require('./main.cjs') or import './main.cjs'); Node does not resolve ./main to ./main.cjs automatically.

2. The whole package should be CommonJS — set its type explicitly#

If "type": "module" was added only to silence ERR_REQUIRE_ESM on one dependency, and the rest of the project is CommonJS (common in an existing NestJS/Express codebase), set "type": "commonjs" explicitly and instead load that one ESM-only dependency with a dynamic import() — which the ERR_REQUIRE_ESM message itself already names as the supported path for CommonJS code reaching into an ES module. Dynamic import expressions are valid in CommonJS, including at the top level when the returned promise is handled with .then(). Awaiting one belongs inside an async function: CommonJS does not support top-level await. An explicit CommonJS package type prevents syntax detection from reclassifying the file. For an awaited result, call it from an async function:

js
// instead of: const { bundle } = require('@nuxt/vite-builder-edge');
async function loadBundler() {
  const { bundle } = await import('@nuxt/vite-builder-edge');
  return bundle;
}

For TypeScript, inspect the emitted JavaScript as well: compiling this expression with the CommonJS module target can turn it back into require(). Use an appropriate Node-aware module mode that preserves dynamic import in CommonJS output, such as NodeNext, and check the generated file before treating this as a fix for an ESM-only dependency. Changing the runtime package type alone does not change what the compiler emits.

This remedy was not run in the reproduction below — it is the documented behaviour of import() from CommonJS, not a tested fix for this article.

Reproduced instead, as the negative control: the same CommonJS style of code — module.exports.hello = …, not the exports.hello = … shorthand that fails under "type": "module" — run under a sibling package.json with explicit "type": "commonjs" runs unchanged, printing hi2 with no error.

3. The compiled output should be ESM — change what tsc emits, not the file#

If the project is TypeScript and "type": "module" is staying, the compiler needs to stop emitting exports.foo = …. In tsconfig.json, change "module" from "commonjs" to an ES module target ("ES2020", "ES6", or the newer "NodeNext", which also matches Node's own resolution rules) so tsc emits export/import statements instead of exports/require calls. This is exactly what the accepted answer on the original question did.

Reproduced: a file using export function hello() { … } (the shape tsc emits under an ES module target) runs with no error under "type": "module" — same runtime, same file extension, only the statement shape changed.

One side effect worth knowing before you flip that switch: Node's own ESM resolver, unlike tsc's classic CommonJS resolution, does not add missing file extensions. A relative import that worked as import './app.module' under "module": "commonjs" needs its extension spelled out — import './app.module.js' — once the output is ESM, even though the source file is app.module.ts. This is the next thing the Stack Overflow asker hit immediately after switching, and it is a separate, expected change in resolution behaviour, not a new bug.

Verify the fix#

Run the entry point directly and confirm there is no ReferenceError, then check the specific statement shape in the file that used to fail:

bash
node path/to/the/file/that/threw.js
grep -n "^exports\.\|^module.exports" path/to/the/file/that/threw.js

If the chosen remedy was to emit ESM but that grep still finds CommonJS assignments, inspect whether stale output remains. CommonJS assignments are expected when the chosen remedy was .cjs or an explicit CommonJS package type. On a TypeScript project, re-run tsc --build --force after editing tsconfig.json: a stale compiled .js file in dist/ can still carry the old exports. lines even after the source .ts and the config have been fixed.

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.

Check your own module error#

exports is not defined in ES module scope is one specific symptom of a broader family of ESM/CommonJS mismatches — ERR_REQUIRE_ESM, Cannot use import statement outside a module, ERR_UNKNOWN_FILE_EXTENSION ".ts", and a handful of others all trace back to the same root cause: some part of the toolchain expected CommonJS and got an ES module, or vice versa. Paste the exact error below to see which side of the mismatch you are on:

Runs entirely in your browser — nothing you paste is sent anywhere. Tokens and passwords that appear in paths or URLs are masked in the output.

Optional: package.json, file extension, Node.js version (sharper advice)
Try:

The same family shows up with a different symptom in native ESM tests: Jest's jest object needs explicit access, and using jest.fn() without that import throws ReferenceError: jest is not defined — see the fix for that one if switching a project to ESM breaks its test suite next.

For a plain JavaScript file with one module format, test the relevant file extension or package-type change before making unrelated edits. A TypeScript build may need its compiler output and runtime settings aligned together; check the emitted file rather than assuming a package-type edit handles both. If the compiler's output shape is the actual cause (point 3 above), TS2305: Module Has No Exported Member covers a separate compile-time import/export mismatch, before the file runs. A separate subpath-resolution error in the same family is Fix ERR_PACKAGE_PATH_NOT_EXPORTED — governed by the package's "exports" map and whether the caller used require() or import, not by the same "type" mechanism as this error.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Written by

Mahdi Ben Rhouma
Mahdi Ben Rhouma

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote