You hit SyntaxError: Cannot use import statement outside a module
because your runtime treats a file as CommonJS (CJS) while your code uses ECMAScript module (ESM) syntax. This guide walks you through reproducible fixes in Node.js and common tooling (TypeScript, Jest, and friends), plus how to reason about mixed-module projects.
What this error actually means:
Node.js supports two module systems: CJS (require
, module.exports
) and ESM (import
, export
). Node decides which one to use per file based on explicit signals, not on whether it sees an import
keyword. If Node decides “this is CJS” and then encounters import
, it throws the error you’re seeing. You fix it by making Node unambiguously interpret the file as ESM, or by running the code under a toolchain that understands ESM.
The minimal, reliable ESM setup in Node.js
Start by picking one of the two canonical ways to mark a file as ESM. Keep it consistent within a package to avoid surprises.
Option A: Declare the whole package as ESM.
Add "type": "module"
to your nearest package.json
. Node will treat all .js
files in that package as ESM unless told otherwise.
{
"name": "my-app",
"type": "module",
"version": "1.0.0",
"private": true
}
This switch flips Node’s default for .js
from CJS to ESM in that package scope and avoids the error. If you need a CJS file in an ESM package, give that file a .cjs
extension.
Option B: Use the .mjs
extension.
Rename files that use import
/export
to .mjs
. Node treats .mjs
as ESM regardless of package.json
.
mv server.js server.mjs
node server.mjs
This is precise and low-risk in mixed codebases and libraries that must remain mostly CJS.
Edge case: running code from stdin or REPL.
When piping code or using node -e
, add --input-type=module
so Node parses the snippet as ESM:
echo "import fs from 'node:fs'; console.log('ok')" | node --input-type=module
This avoids the error outside the filesystem context.
Verify your import paths and file extensions
Once the runtime is ESM-aware, incorrect paths can still produce module errors that look similar. In ESM, relative imports must include extensions or resolve via an exports
map. Update bare relative imports:
- import util from './util';
+ import util from './util.js'; // or './util.mjs' or the actual extension
If you publish a package, prefer defining an exports
field in package.json
and import via the package name; Node’s ESM resolver respects it and avoids deep relative paths that are brittle across builds.
Interop with CommonJS: call the right boundary
You often need to call CJS from ESM or vice versa. Use the officially supported interop points rather than ad-hoc workarounds.
From ESM, require
CJS modules via createRequire
:
// file: app.mjs (ESM)
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const legacy = require('./legacy.cjs');
legacy.run();
From CJS, dynamically import ESM modules:
// file: bootstrap.cjs (CJS)
(async () => {
const { start } = await import('./server.mjs');
start();
})();
These patterns are stable across Node versions and keep the module boundary explicit.
TypeScript: align compiler options with Node’s ESM rules
When TypeScript compiles for Node’s native ESM, two flags must agree: module
and moduleResolution
. Use the “NodeNext” (or “node16”) pair, which teaches the compiler Node’s ESM/CJS detection and path rules (including the need for file extensions in relative ESM imports).
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"strict": true,
"esModuleInterop": true, // interop for common cjs typings
"resolveJsonModule": true
}
}
If your project is marked "type": "module"
, the emitted .js
in dist
will be ESM and follow Node’s expectations; if not, .cjs
may be emitted where appropriate. Don’t mix module: NodeNext
with moduleResolution: Bundler
or node10
, because that breaks the invariant the compiler relies on.
Run TS entry points without fighting loaders.
Instead of ts-node --esm
or custom loaders, use tsx
for local dev:
# Install once
npm i -D tsx
# Run your ESM TypeScript directly
npx tsx src/index.ts
This keeps runtime semantics close to Node’s ESM and avoids configuration drift. (If you must use ts-node
, use node --loader ts-node/esm
and the NodeNext config above.)
Jest: make tests understand ESM (or use Vitest)
Jest’s ESM support is still flagged and requires explicit config. If you run ESM code under Jest without these, you’ll see the same “outside a module” error from the test runner, not Node.
Create jest.config.ts
as an ESM config and mark extensions to treat as ESM:
// jest.config.ts (ESM)
import type { Config } from 'jest';
const config: Config = {
testEnvironment: 'node',
extensionsToTreatAsEsm: ['.ts', '.tsx'],
transform: {}, // keep empty when compiling TS via tsx/tsc beforehand
};
export default config;
Run Jest with Node’s VM modules enabled (as required by Jest’s ESM layer):
node --experimental-vm-modules ./node_modules/jest/bin/jest.js
If that feels heavy, switch to Vitest, which has first-class ESM/TS support with less ceremony. The key point is that many “outside a module” failures inside test runners are runner configuration, not your app.
Using ts-jest?
Keep module
at least ES2022
/ESNext
in your test tsconfig and follow their ESM guide; otherwise ts-jest will compile into CJS and reintroduce the error.
Tooling hotspots that commonly trigger the error
Your code can be correct yet still fail in wrappers that default to CJS.
Nodemon & ts-node. Run through Node with ESM-aware commands:
nodemon --exec "node" src/server.mjs
# or, with TypeScript via tsx
nodemon --exec "tsx" src/server.ts
CLI scripts. For executable scripts in an ESM package, use .mjs
and a Node shebang:
#!/usr/bin/env node
// file: bin/cli.mjs
import { main } from '../src/main.js';
main();
Make the file executable with chmod +x bin/cli.mjs
and set "bin"
in package.json
. This keeps downstream environments from guessing module type.
Bundlers and browsers. If you compile for browsers, rely on the bundler’s resolver and keep TS at module: ESNext
and moduleResolution: Bundler
. Do not point the browser at raw Node-targeted ESM that imports Node built-ins like node:fs
.
Migrating a mixed CJS/ESM codebase without downtime
Move in small, explicit steps and keep boundaries sharp.
- Mark the package as ESM with
"type": "module"
or convert a leaf folder to.mjs
. Keep any remaining CJS as.cjs
. - Convert entry points first, then utilities. Where conversion is not immediate, cross the boundary with
createRequire
or dynamicimport()
as shown earlier. - For published packages, add an
exports
map with both ESM and CJS entries so consumers can choose:
{
"name": "lib-dual",
"type": "module",
"exports": {
".": {
"import": "./dist/index.js", // ESM
"require": "./dist/index.cjs" // CJS
}
}
}
- Run tests with the ESM-aware test runner configuration and enforce path extensions in imports to catch regressions early.
Quick diagnostic flow when the error appears
Start from the file throwing the error and answer three questions in order.
Is the file clearly marked as ESM?
It must be .mjs
, or live under a package.json
with "type": "module"
, or be executed with --input-type=module
. Fix whichever is missing.
Is the tool that’s running it ESM-aware?
Jest, ts-node, nodemon, and some CLIs wrap Node and may default to CJS. Configure them to respect ESM or switch to tsx
/Vitest during the migration.
Are the import paths valid under ESM?
Use file extensions on relative imports, avoid directory indexes unless you provide an exports
map, and don’t rely on CJS resolution quirks.
End-to-end example: modern ESM with TypeScript, tests, and a CLI
This skeleton compiles cleanly, runs locally and on a new droplet, and avoids the “outside a module” trap.
// package.json
{
"name": "modern-esm-app",
"type": "module",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "tsx src/index.ts",
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
}
}
// tsconfig.json
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"strict": true,
"esModuleInterop": true
},
"include": ["src"]
}
// src/index.ts
import { greet } from './lib/greet.js';
export function start(): void {
console.log(greet('ESM'));
}
if (import.meta.url === `file://${process.argv[1]}`) {
start();
}
// src/lib/greet.ts
export const greet = (name: string): string => `Hello, ${name}!`;
// jest.config.ts
import type { Config } from 'jest';
const config: Config = {
testEnvironment: 'node',
extensionsToTreatAsEsm: ['.ts'],
};
export default config;
// src/lib/greet.test.ts
import { greet } from './greet.js';
test('greets by name', () => {
expect(greet('ESM')).toBe('Hello, ESM!');
});
Run the app locally with npm run dev
, build with npm run build
, and run tests with npm test
. On a new server, install the latest Node LTS and run the same commands; the explicit ESM signals and extensions ensure identical behavior.
Frequently confusing situations
“It works in Node, but my tests fail with the same error.”
Your test runner is executing files as CJS. Apply the Jest config and launcher shown above or switch to Vitest.
“Adding type: module
broke some scripts.”
Rename CJS-only scripts to .cjs
and, if you publish a package, provide dual exports
. Avoid relying on extensionless imports in ESM—add the extension explicitly.
“TypeScript compiles, but Node still errors.”
Your TS compiler output doesn’t match Node’s resolver expectations. Confirm module: NodeNext
+ moduleResolution: NodeNext
, and check emitted file extensions and import specifiers.
Wrap-up
The error is a symptom of an unmarked or mis-resolved ESM context. Mark your modules explicitly ("type": "module"
or .mjs
), align TypeScript’s module
and moduleResolution
with Node’s rules, and ensure your test runner and dev tools run with ESM enabled. Once those three layers agree—runtime, compiler, and tooling—the error disappears and your code runs consistently on your laptop or on a fresh droplet.
Assumptions used here: Node.js LTS or newer on your machine or server; npm-based workflow; and a clean project without legacy bundler constraints. If your setup differs, follow the same reasoning: make the runtime recognize ESM, make the compiler emit compatible code, and configure wrappers (tests, CLIs) to honor ESM.