Blog
4 min read

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:

  1. .mjs files are always ESM; .cjs files are always CommonJS.
  2. .js files follow the nearest package.json's "type" field:
    • "type": "module" → ESM
    • "type": "commonjs" or missing → CommonJS
  3. Recent Node versions also detect ESM syntax in ambiguous .js files 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 .js extension 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

  1. Add "type": "module" to package.json.
  2. Replace require with import, module.exports with export.
  3. Add .js extensions to relative imports.
  4. Replace __dirname/__filename.
  5. Rename any config files that must stay CommonJS to .cjs.
  6. 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/.cjs decide by extension; .js follows package.json "type".
  • "Cannot use import" → set "type": "module". "require is not defined" → use import or createRequire.
  • ESM in Node needs file extensions; __dirname becomes import.meta.dirname.
  • Match TypeScript's module/moduleResolution to 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