Skip to content

Node.js: Cannot Use Import Statement Outside a Module

Node.js's SyntaxError: Cannot use import statement outside a module still fires today, usually from "type": "commonjs". Here is the exact fix and why.

nodejs nodejs-24 nodejs-errors esm commonjs package-json nodejs-26
Bharath G
Reading Progress

On This Page

The Error

/app/app.js:1
import { add } from './math-utils.js';
^^^^^^

SyntaxError: Cannot use import statement outside a module
    at wrapSafe (node:internal/modules/cjs/loader:1637:18)
    at Module._compile (node:internal/modules/cjs/loader:1679:20)
    at Object..js (node:internal/modules/cjs/loader:1838:10)
    at Module.load (node:internal/modules/cjs/loader:1441:32)
    at Function._load (node:internal/modules/cjs/loader:1263:12)
    at TracingChannel.traceSync (node:diagnostics_channel:328:14)
    at wrapModuleLoad (node:internal/modules/cjs/loader:237:24)
    at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:171:5)
    at node:internal/main/run_main_module:36:49

Node.js v22.22.2

That is the exact, unedited output from a live Node.js 22.22.2 process. The same file also prints a warning immediately before the stack trace on any version that has module-syntax detection enabled:

Warning: Failed to load the ES module: /app/app.js. Make sure to set "type": "module" in the package.json or use the .mjs extension.

The frame list is version-sensitive. On Node.js 21.7.3 (which never got the fix described below) the same failure prints a shorter, older stack with different internal line numbers and no TracingChannel.traceSync frame:

Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension.
/app/app.js:1
import { add } from './math-utils.js';
^^^^^^

SyntaxError: Cannot use import statement outside a module
    at internalCompileFunction (node:internal/vm:128:18)
    at wrapSafe (node:internal/modules/cjs/loader:1279:20)
    at Module._compile (node:internal/modules/cjs/loader:1331:27)
    at Module._extensions..js (node:internal/modules/cjs/loader:1426:10)
    at Module.load (node:internal/modules/cjs/loader:1205:32)
    at Module._load (node:internal/modules/cjs/loader:1021:12)
    at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:142:12)
    at node:internal/main/run_main_module:28:49

Node.js v21.7.3

The TracingChannel.traceSync and wrapModuleLoad frames on 22.x/24.x/26.x come from the diagnostics-channel instrumentation wrapped around CJS loading in the Node 22 loader refactor — cosmetic for this error, but they're exactly what a reader will paste into a search box, so both forms are worth recognizing. The sibling wording, SyntaxError: Unexpected token 'export', comes from the identical code path when the offending line is an export statement instead of import — same root cause, same fix, one article.

How to Reproduce It (step-by-step)

The catch in 2026 is that this error is harder to trigger than it used to be, because Node now guesses your module system before giving up. Here is the minimal case that still reliably reproduces it.

Directory layout:

app/
├── package.json
├── app.js
└── math-utils.js

math-utils.js (ESM syntax, CommonJS filename):

javascript

export function add(a, b) {
  return a + b;
}

app.js:

javascript

import { add } from './math-utils.js';

console.log(add(2, 3));

package.json — the field that actually forces the failure:

json

{
  "name": "app",
  "version": "1.0.0",
  "type": "commonjs"
}

Run it:

bash

node app.js

Output: the exact trace in Section 1. The critical detail is "type": "commonjs". If you instead delete the "type" field entirely and rerun on Node 20.19+, 22.7+, 24.x, or 26.x, it does not throw — it prints a MODULE_TYPELESS_PACKAGE_JSON warning and quietly runs the file as ESM:

bash

$ rm package.json && node app.js
(node:1958) [MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of file:///app/app.js is not specified and it doesn't parse as CommonJS.
Reparsing as ES module because module syntax was detected. This incurs a performance overhead.
To eliminate this warning, add "type": "module" to /app/package.json.
5

So the four situations that still produce the hard SyntaxError today are:

  1. package.json explicitly sets "type": "commonjs" (verified above — this is the #1 real-world trigger, usually from a scaffolding tool, a monorepo root config, or a "type" field copied from another package).
  2. The file has a .cjs extension — extension always wins over auto-detection:

bash

   $ cp app.js app.cjs && node app.cjs
   SyntaxError: Cannot use import statement outside a module
  1. You're running Node.js 21.x, or any Node.js 18.x/20.x build older than 20.19.0 — versions that predate the syntax-detection backport (see Section 3).
  2. Detection is explicitly disabled with --no-experimental-detect-module.

Trigger flags worth knowing while debugging this: --trace-warnings shows where the MODULE_TYPELESS_PACKAGE_JSON warning originates, and NODE_DEBUG=module node app.js dumps the CJS loader's resolution and compile steps so you can see exactly which file failed the CommonJS parse first.

Version Behaviour Matrix (Node.js 20 / 22 / 24 / 26)

VersionBehavior on an ambiguous .js file (no "type" field)Behavior with "type": "commonjs" or .cjs
20 (EOL 30 Apr 2026)Auto-detects and reparses as ESM since 20.19.0 (backported, PR #53619); throws on 20.18.x and earlierAlways throws SyntaxError: Cannot use import statement outside a module
21 (EOL, never LTS)Always throws — syntax detection was never backported to the 21.x lineAlways throws
22 (Maintenance LTS, EOL 30 Apr 2027)Auto-detects and reparses as ESM since 22.7.0 (blog)Always throws
24 (Active LTS, EOL 30 Apr 2028)Auto-detects and reparses as ESM (inherits the 22.7.0+ default; unchanged since)Always throws
26 (Current, released 22 Apr 2026)Same auto-detection, now fully stable and undocumented-as-experimental in the packages referenceAlways throws

--experimental-detect-module itself was introduced experimentally in Node 20.10.0 and is unflagged (on by default) on every currently supported line; --no-experimental-detect-module still exists if you need the old strict behavior for a build tool that depends on it. There is no DEP#### deprecation ID for this — it's a default-behavior change, not a removal.

Looking ahead: Node.js 27 is the first release under the new yearly, calendar-aligned schedule — Alpha starts October 2026, 27.0.0 ships April 2027, and it goes straight to LTS in October 2027 with no odd/even split. Nothing currently on the roadmap changes detection behavior again; if anything is planned, it will land as a further default flip, not a new flag.

Why It Happens — Surface Level

The CommonJS loader (node:internal/modules/cjs/loader) compiles every .js file it's handed by wrapping it in a function and running it through V8 as a script, not a module. V8 scripts don't understand import/export — those are module-record-only syntax — so the parser throws a SyntaxError at the first import or export token it sees, before your code ever executes.

Whether a .js file reaches that CommonJS compile step in the first place depends on the nearest package.json's "type" field: absent or "commonjs" → CommonJS; "module" → ESM. .mjs is always ESM and .cjs is always CommonJS regardless of "type". Since 2024's syntax-detection default flip, an absent "type" field is no longer a guaranteed trip to the CJS compiler — Node tries CJS first and silently retries as ESM if that parse fails on module syntax. An explicit "commonjs" skips that retry entirely, which is why it's now the most common real-world trigger.

Why It Happens — Under the Hood

Module._extensions['.js'] (surfaced today as Object..js in the v22+ stack) is the function that decides how to compile a .js file. Before the detection feature existed, it unconditionally called wrapSafe(), which calls V8's vm.Script/compileFunction in sloppy script mode — not module mode — so import/export are just invalid tokens to the parser, and the SyntaxError is thrown synchronously during compilation, before Module._compile ever runs your code.

Syntax detection changes what happens on failure, not how the CJS compile itself works. wrapSafe() now runs inside a try/catch: if V8 throws specifically because of import/export/import.meta syntax (checked via V8's ContainsModuleSyntax API and matched by the loader against SyntaxError text), and the file is "ambiguous" (.js with no governing "type"), the loader discards the failed CJS attempt, re-wraps the source as an ES module, and hands it to the ESM loader instead — which is why you only see the raw SyntaxError when there's no ambiguity left to exploit: a .cjs extension or an explicit "type": "commonjs" tells Node definitively "this is CommonJS," so there's nothing to retry.

This is a genuinely different code path from the ESM loader you'd hit going the other direction (ERR_REQUIRE_ESM / require(esm), covered in a separate article) — here, the entry point itself is being parsed by the wrong engine, not a dependency being require()'d incorrectly. You can watch the retry happen with NODE_DEBUG=esm node app.js on a version where detection is active, or force the pre-2024 behavior with --no-experimental-detect-module to compare stack traces directly, as shown in Section 2.

The v22+ stack's extra frames (TracingChannel.traceSync, wrapModuleLoad) come from node:diagnostics_channel instrumentation added around Module._load as part of the ongoing CJS loader modularization — unrelated to this bug, but a good example of why pasting the exact trace into a search matters more than pasting just the message: the frame shape alone tells you roughly which Node major produced it.

To Fix

Quick fix — declare the package as ESM (correct when your whole package is meant to be ESM, which is the common case for new code):

diff

 {
   "name": "app",
   "version": "1.0.0",
-  "type": "commonjs"
+  "type": "module"
 }

bash

$ node app.js
5

Quick fix — rename the entry file to .mjs when you can't touch "type" (e.g. it's a shared monorepo-root package.json you don't control):

bash

mv app.js app.mjs

Watch out: renaming only the entry point isn't always enough. If app.mjs imports a sibling file that's still ambiguous (math-utils.js, no .mjs/.cjs, no governing "type": "module"), that sibling still goes through the CJS-first detection dance and prints the MODULE_TYPELESS_PACKAGE_JSON warning on every run — harmless, but it's the kind of noise that gets mistaken for a real bug. Rename every ESM file consistently, or set "type": "module" at the package root instead of patching file-by-file.

Correct fix for a mixed CJS/ESM package — keep "type": "commonjs" at the root (so your require()-based files keep working unmodified) and mark only the ESM files explicitly:

 app/
   package.json         # "type": "commonjs" (unchanged)
   legacy.js             # untouched CommonJS, require()'d elsewhere
-  math-utils.js
+  math-utils.mjs
   app.js

javascript

// app.js — still CommonJS; require(esm) makes this interop trivial since Node 20.19/22.12+
const { add } = require('./math-utils.mjs');
console.log(add(2, 3));

Since require(esm) became unflagged (Node 20.19.0, 22.12.0, and stable thereafter), a CommonJS file can require() an .mjs module directly as long as that module has no top-level await — no import() gymnastics needed. That's frequently the least invasive fix in a large CJS codebase that only needs one or two new ESM-only dependencies.

Practices & Better Design

The fix that prevents this class of bug from recurring is to stop relying on Node's ambiguity-resolution at all: declare "type" explicitly at every package root, and treat .mjs/.cjs as the tie-breaker only for files that must diverge from the package default. Auto-detection exists to keep old, untyped packages from breaking — it is a compatibility shim, not a design pattern to build on, because it costs a real parse-retry on every ambiguous file and it silently changes behavior the moment someone hand-edits a package.json deep in node_modules or a build step.

json

{
  "name": "app",
  "type": "module",
  "engines": { "node": ">=22.12.0" }
}

Rewritten "the right way," runs as written on Node 22.12.0+:

javascript

// math-utils.js — package type is "module", so plain .js is ESM, no detection needed
export function add(a, b) {
  return a + b;
}

javascript

// app.js
import { add } from './math-utils.js';

console.log(add(2, 3));

bash

$ node app.js
5

No warnings, no retry, no ambiguity — because the "type" field settles the question before the loader ever has to guess.

Prevent It in the Long-Term

Put n/no-unsupported-features/node-builtins and n/no-missing-import (from eslint-plugin-n) in your lint config — both catch module-system mismatches (importing a CJS-only path from ESM, or vice versa) before the code ever runs. For TypeScript codebases, set "moduleResolution": "nodenext" in tsconfig.json: it forces you to be explicit about .js extensions and module kind at the type-checking layer, which surfaces this exact class of error as a compile error (TS1479) instead of a runtime crash. Run node --experimental-strip-types --no-experimental-detect-module (or the CI equivalent: --no-experimental-detect-module alone) in at least one CI lane — it forces every ambiguous file to declare itself, turning today's silent auto-fix into a build failure that names the exact file. publint and arethetypeswrong catch the packaging side of this (a published package whose "type", "exports", and shipped file extensions disagree with each other) before your users hit it instead of you.

At the process level: pin "type" explicitly in every package.json you author (root and any nested workspace package), never inherit it implicitly from a parent; keep new code on .js files under an explicit "type": "module" rather than leaning on .mjs/.cjs extensions as the primary signal, since extensions are easy to get wrong on a copy-paste and give no compile-time warning; and when scaffolding a new service, run one smoke test in CI that executes the real entry point with node, not just through ts-node/tsx/a bundler — those tools frequently have their own, more permissive module resolution that will not catch this before it reaches production.

This connects directly to ERR_MODULE_NOT_FOUND and ERR_REQUIRE_ESM/require(esm) interop (both covered separately) — all three are symptoms of the same underlying question, "which loader owns this file," and a package with an explicit, consistent "type" field avoids all three at once.

nodejsnodejs-24nodejs-errorsesmcommonjspackage-jsonnodejs-26

From aspiring developer to blogger, I test learning platforms, simplify programming syntax, and share resources that work. Helping you code smarter as I grow myself. New or experienced, you're welcome here.

Comments