ESM vs CommonJS: import vs require and the Errors Between Them
JavaScript has two module systems. CommonJS uses require and module.exports; ES modules use import and export. How Node decides which a file is, and how to fix 'Cannot use import statement outside a module', 'require is not defined', ERR_REQUIRE_ESM and __dirname is not defined.
JavaScript has two ways to split code into modules, and Node.js supports both. Most of the time a bundler hides the difference. When it doesn't, you get some of the most confusing errors in the ecosystem.
The two systems
CommonJS (CJS) — Node's original system:
const express = require('express')
const { formatDate } = require('./utils')
module.exports = { createServer }
ES modules (ESM) — the JavaScript standard, used by browsers and modern Node:
import express from 'express'
import { formatDate } from './utils.js'
export function createServer() {}
Key differences:
| CommonJS | ES modules | |
|---|---|---|
| Syntax | require, module.exports |
import, export |
| Loading | Synchronous, at runtime | Static, analysed before running |
Top-level await |
No | Yes |
| File extensions in imports | Optional | Required for relative paths in Node (./utils.js) |
__dirname, __filename |
Available | Not defined (use import.meta.dirname / import.meta.filename) |
| Tree-shaking | Hard | Easy |
| Browser support | No | Yes |
New code should be ESM. The ecosystem has largely moved, and many popular packages now publish ESM-only.
How Node decides which a file is
In order:
.mjsfiles are always ESM;.cjsfiles are always CommonJS..jsfiles follow the nearestpackage.json's"type"field:"type": "module"→ ESM"type": "commonjs"or missing → CommonJS
- Recent Node versions also detect ESM syntax in ambiguous
.jsfiles and run them as ESM, but relying on that is fragile — set"type"explicitly.
{ "type": "module" }
The errors, and their fixes
"Cannot use import statement outside a module"
SyntaxError: Cannot use import statement outside a module
Node is treating the file as CommonJS, but it contains import. Fix: add "type": "module" to package.json, or rename the file to .mjs. (In Jest, this error usually means the test runner isn't transforming ESM — Vitest avoids most of this.)
"require is not defined in ES module scope"
The opposite: the file is ESM, but uses require. Convert to import, or if you truly need require (e.g. for JSON or a CJS-only path):
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
"__dirname is not defined in ES module scope"
// modern Node
const dir = import.meta.dirname
const file = import.meta.filename
// older Node
import { fileURLToPath } from 'node:url'
import path from 'node:path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
ERR_REQUIRE_ESM: "require() of ES Module … not supported"
Your CommonJS code requires a package that's ESM-only. Options:
- Convert your project to ESM (best long-term).
- Use dynamic import:
const { default: pkg } = await import('pkg'). - Newer Node versions can
require()synchronous ES modules, which removes this error in many cases — upgrading Node may fix it outright. - Pin to the package's last CJS version (temporary).
"Cannot find module './utils'" (in ESM)
ESM in Node needs the full file name, including the extension: ./utils.js. Even in TypeScript source, you write .js (the compiled output's name) when using Node's module resolution. (Cannot find module)
"The requested module does not provide an export named …"
Importing a named export from a CommonJS package that Node can't statically detect. Import the default and destructure:
import pkg from 'some-cjs-package'
const { thing } = pkg
TypeScript settings
TypeScript has its own module settings that must match how the code runs:
- Running directly in Node (no bundler):
"module": "nodenext"and"moduleResolution": "nodenext". TypeScript then follows Node's rules, including the.jsextension requirement and"type"field. - Bundled (Vite, Next.js, esbuild):
"module": "esnext"/"preserve"with"moduleResolution": "bundler"— extensions optional, the bundler resolves imports.
Mismatches here cause the errors above at runtime even when type-checking passes. (What is TypeScript?)
Why you rarely see this in frontend code
Vite, Next.js and other bundlers accept both styles and output whatever the browser or server needs. The errors bite in Node scripts, servers, test runners and CLIs — and in AI-generated code that mixes require and import in one project. Tell your coding agent which system you use in CLAUDE.md.
Converting a project to ESM
- Add
"type": "module"topackage.json. - Replace
requirewithimport,module.exportswithexport. - Add
.jsextensions to relative imports. - Replace
__dirname/__filename. - Rename any config files that must stay CommonJS to
.cjs. - Run the app and tests; fix the stragglers.
This is a mechanical change that coding agents do well, especially with tests to verify.
The summary
- CommonJS:
require/module.exports. ESM:import/export. Prefer ESM. .mjs/.cjsdecide by extension;.jsfollowspackage.json"type".- "Cannot use import" → set
"type": "module". "require is not defined" → useimportorcreateRequire. - ESM in Node needs file extensions;
__dirnamebecomesimport.meta.dirname. - Match TypeScript's
module/moduleResolutionto how the code runs.
EasySpawn runs your Node app on the same Linux server where Claude Code develops it, so module errors surface — and get fixed — before deploy. See how it works or join the waitlist.
Related: What Is Node.js? · Cannot Find Module · Node.js vs Bun vs Deno · What Are npm and package.json?
Keep reading
How to Test Webhooks Locally: Stripe CLI, Tunnels, and Replays
Webhook providers can't reach localhost, so local testing needs a forwarder or a tunnel. How to use provider CLIs like stripe listen, general tunnels like ngrok and Cloudflare Tunnel, request-capture tools, and fixture replays in automated tests — plus the signature-verification gotchas.
Playwright Tutorial: End-to-End Tests That Aren't Flaky
Playwright drives real browsers to test your app the way users use it. Install it, write your first test, use role-based locators and auto-waiting assertions, log in once and reuse the session, run against a dev server, debug with UI mode and traces, and run it in CI.