Node.js has two module systems living side by side. CommonJS (require and module.exports) is the original, and most of npm was written in it. ES modules (import and export) are the JavaScript standard, and new code increasingly uses them. They don’t just differ in syntax: they resolve file names differently, load at different times, and expose different variables. Most confusing Node errors at the start of a project (Cannot find module, require is not defined, Named export not found) come from code crossing the boundary between the two without realising it.
Interviewers ask about this at a beginner level because it’s practical: “Why does this import fail when the file is clearly there?” The answers below were all checked against Node 22.23.
How Node decides which system a file uses
Node looks at the file extension first:
.mjsis always an ES module..cjsis always CommonJS..jsfollows the"type"field in the nearestpackage.jsonwalking up from the file:"module"means ESM,"commonjs"or no field means CommonJS.
That last rule has a modern twist. If a .js file has no "type" to go by and contains import or export syntax, Node 22 parses it as CommonJS, fails, and reparses it as ESM. It works, with a warning when a package.json exists:
ran as ESM, platform darwin
(node:95518) [MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of file:///notype/x.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 /notype/package.json.The warning tells you exactly what to do. Set "type" explicitly in every package you own, so nobody has to guess and Node doesn’t parse files twice.
The examples below use a small project with "type": "module":
app/
package.json { "name": "app", "type": "module" }
lib/math.js export const add = (a, b) => a + b;
lib/index.js export * from './math.js';
node_modules/legacy/ a CommonJS package: { "main": "main.js" }ESM resolution is exact
CommonJS require is forgiving. require('./lib/math') tries math, then math.js, math.json and math.node, then treats math as a directory and looks for package.json main or index.js. A lot of code depends on that without knowing it.
ES modules don’t do any of it. The specifier is a URL, and Node resolves it exactly as written. Leave off the extension:
// t1.js
import { add } from './lib/math';Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/app/lib/math' imported from /app/t1.js
Did you mean to import "./lib/math.js"?
code: 'ERR_MODULE_NOT_FOUND',(Paths shortened to /app.) Import a directory expecting index.js:
// t2.js
import { add } from './lib';Error [ERR_UNSUPPORTED_DIR_IMPORT]: Directory import '/app/lib' is not supported resolving ES modules imported from /app/t2.jsWrite ./lib/math.js and ./lib/index.js. This catches people coming from bundlers like webpack or Vite, which apply CommonJS-style guessing to ESM imports. Code that works in a bundled frontend can fail when the same files run directly under Node.
TypeScript adds its own twist: with "module": "nodenext", you write import './math.js' in a .ts file even though the source file is math.ts, because the specifier has to match what Node will see after compilation. It looks wrong the first time, and it’s correct.
Bare specifiers and node_modules
A specifier that isn’t relative or absolute, like 'legacy' or 'express', is a bare specifier, and both systems resolve it by walking up the directory tree. From /app/lib/report.js, Node checks /app/lib/node_modules/legacy, then /app/node_modules/legacy, then /node_modules/legacy. Once it finds the package folder, it reads the package’s package.json: the "exports" field if present (which can block deep imports entirely; see the package exports note), otherwise "main", otherwise index.js.
That walk explains a class of “works on my machine” bugs. A dependency that isn’t in your package.json can still resolve because it happens to be hoisted into a parent node_modules by another package, or exists in a node_modules higher up your file system. Then a clean install in CI or a stricter package manager like pnpm removes it, and you get Cannot find module. The Cannot find module guide walks through diagnosing that.
Importing CommonJS from ESM
ESM can import CommonJS packages. The CommonJS module.exports object always arrives as the default export, and Node also tries to offer named exports by statically scanning the file for patterns like exports.name = ...:
import legacy, { greet, version } from 'legacy';
console.log(greet('ana'), version, Object.keys(legacy));hi ana 1.0.0 [ 'greet', 'version', 'dynamic' ]The scan works only for assignments it can see without running the code. A package that builds its exports at runtime, such as module.exports = build(), has nothing to detect:
import { greet } from 'factory';SyntaxError: Named export 'greet' not found. The requested module 'factory' is a CommonJS module, which may not support all module.exports as named exports.
CommonJS modules can always be imported via the default export, for example using:
import pkg from 'factory';
const { greet } = pkg;The error message gives you the fix. Import the default and destructure it.
Requiring ESM from CommonJS
For years the other direction simply failed: require() of an ES module threw ERR_REQUIRE_ESM, and the only option was await import(). That’s why so many packages that went ESM-only caused breakage for CommonJS users.
Since Node 22.12 (and in Node 20.19), require() can load ES modules synchronously:
// t4.cjs
const { add } = require('./lib/math.js');
console.log('require(esm):', add(2, 3));require(esm): 5The exception is top-level await. A module that awaits at the top level can’t be loaded synchronously, so it fails with a precise error:
Error [ERR_REQUIRE_ASYNC_MODULE]: require() cannot be used on an ESM graph with top-level await. Use import() instead. To see where the top-level await comes from, use --experimental-print-required-tla.The check covers the whole graph, so a top-level await in any module the required file imports triggers it too.
Variables that don’t exist in ESM
CommonJS wraps every file in a function that provides require, module, exports, __filename and __dirname. ES modules have no wrapper, so none of those exist:
ReferenceError: require is not defined in ES module scope, you can use import insteadThe replacements are import statements or import(), import.meta.filename and import.meta.dirname (Node 20.11 and later), and createRequire(import.meta.url) from node:module when you genuinely need require, for example to load a JSON file synchronously or use require.resolve. Checked in the test project, typeof __dirname was 'undefined' inside the ESM file while import.meta.dirname ended with app.
Interview practice
A team switches their service’s package.json to "type": "module". Immediately, node src/server.js fails with ERR_MODULE_NOT_FOUND for ./routes, and after fixing that, a different file fails with ReferenceError: __dirname is not defined. Their tests, which run through a bundler, still pass. Explain both errors and how you’d migrate safely.
Answer
Setting "type": "module" makes every .js file in the package an ES module, so Node now uses ESM resolution rules. import routes from './routes' relied on CommonJS behaviour that tries extensions and directory index.js files. ESM resolves specifiers exactly, so it needs ./routes/index.js. The second error is the same switch: __dirname is provided by the CommonJS module wrapper, which ES modules don’t have; the replacement is import.meta.dirname, or path.dirname(fileURLToPath(import.meta.url)) on older versions. The tests pass because the bundler applies its own forgiving resolution, so they aren’t exercising Node’s real loader.
To migrate safely, I’d add explicit extensions and index paths to every relative import (TypeScript with "module": "nodenext" or an ESLint import rule can enforce it), replace __dirname, __filename and require usages, and check any CommonJS dependencies whose named imports Node can’t detect. Then I’d make at least one test run the real entry point with plain node, so CI catches resolution errors the bundler hides. Files that must stay CommonJS can be renamed to .cjs during the transition.