Cannot use import statement outside a module

Debugging “Cannot use import statement outside a module” in ES modules

We may earn an affiliate commission through purchases made from our guides and tutorials.

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.

  1. Mark the package as ESM with "type": "module" or convert a leaf folder to .mjs. Keep any remaining CJS as .cjs.
  2. Convert entry points first, then utilities. Where conversion is not immediate, cross the boundary with createRequire or dynamic import() as shown earlier.
  3. 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
    }
  }
}
  1. 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.

Was this helpful?

Thanks for your feedback!
Alex is the resident editor and oversees all of the guides published. His past work and experience include Colorlib, Stack Diary, Hostvix, and working with a number of editorial publications. He has been wrangling code and publishing his findings about it since the early 2000s.

Leave a comment

Your email address will not be published. Required fields are marked *