Right, let’s talk about the awkward handshake between the two worlds. You’re in ES Module land, living your best life with import and export, but then you look over the fence and there’s a giant pile of legacy code written in CommonJS (require and module.exports). You can’t just ignore it. Node.js had to figure out a way to make these two talk to each other without everything exploding, and they called it “interop.” It’s mostly graceful, except for the parts where it’s absolutely not.

The first thing to burn into your brain is this: In Node.js, ES Modules can import CommonJS modules. But CommonJS modules cannot require() ES Modules. It’s a one-way street. The ES Module system is the new, more powerful kid on the block; it had to be built to understand the old ways. The require() function, however, is old and set in its ways—it has no concept of this newfangled import syntax. If you try to require() an ES Module, Node.js will throw an ERR_REQUIRE_ESM error at you. It’s a hard boundary.

How import Handles a CommonJS Module

When you import from a CommonJS module, Node.js performs a small bit of magic behind the curtains. It treats the entire CommonJS module as if it had a single default export. Remember, CommonJS exports an object (module.exports). The ES Module spec has this concept of a “default” export, so Node.js just says, “Alright, your entire module.exports object is the default export.”

// commonjs-module.js
module.exports = {
  someFunction: () => console.log('Hello from CommonJS'),
  someValue: 42
};

// es-module.mjs (or in a package with "type": "module")
import commonJsModule from './commonjs-module.js'; // The whole object is the default import

console.log(commonJsModule.someValue); // 42
commonJsModule.someFunction(); // "Hello from CommonJS"

You can also use namespace imports (import * as), but it’s essentially the same thing. You’re just getting the default export under a different name.

import * as namespace from './commonjs-module.js';
console.log(namespace.default.someValue); // Notice the .default? It's the same object!

Wait, what? Why is there a .default? This is one of those weird interop choices. The namespace object you get isn’t just the exported object; it’s a special module object where the actual exports from the CommonJS file are placed on its default property. It’s confusing, I know. Just stick with the default import syntax for CommonJS modules; it’s cleaner.

The module.exports vs. exports Trap

This is a classic JavaScript footgun, and it’s vital you understand it to avoid head-scratching bugs. module.exports is the real export. The exports variable is just a convenience reference to module.exports when the module starts.

If you assign a new value to exports, you’ve broken the reference. You’re now pointing the exports variable at a new object, but module.exports is still pointing at the original, empty one. Node.js will only ever export what module.exports points to at the end of your file.

// broken.js
exports = { a: 1 }; // BAD! Broke the reference. `module.exports` is still {}
module.exports = { b: 2 }; // GOOD! This is what gets exported.

// working.js
exports.c = 3; // GOOD! This adds a property to the object `module.exports` points to.
module.exports.d = 4; // ALSO GOOD!

The best practice? Just forget exports exists. Always use module.exports. It’s one less mental model to worry about.

What About Named Imports?

Here’s where the illusion breaks down a little. You cannot use named imports (import { something } from...) with a true CommonJS module. The following will fail:

// commonjs-module.js
module.exports = { namedThing: 'oops' };

// es-module.mjs
import { namedThing } from './commonjs-module.js'; // Error! No named export 'namedThing'.

The CommonJS module is treated as a single default export, not a module with named exports. However, to make life slightly easier, Node.js and bundlers will often provide a syntax sugar: they will automatically do the destructuring for you if you use a named import on a CommonJS module that exports an object.

// This might actually work in your environment, but it's a trick!
import { namedThing } from './commonjs-module.js'; // Seems to work?!

Don’t rely on this. It’s non-standard behavior that tools might provide. The safe, canonical way is to import the default and then destructure or access the property.

import commonJsModule from './commonjs-module.js';
const { namedThing } = commonJsModule; // Do this instead.

The import Syntax in CommonJS? No.

Remember the one-way street. Inside a CommonJS file (.js without "type": "module"), you cannot use the import statement. It’s a syntax error. Your only option for bringing in an ES Module from a CommonJS context is to use the import() function, which is a promise-based dynamic import.

// commonjs-script.js
// const myModule = require('./es-module.mjs'); // ERROR: ERR_REQUIRE_ESM

// You must use dynamic import instead
async function main() {
  const myModule = await import('./es-module.mjs');
  console.log(myModule.default.someExportedValue); // Access via .default
  console.log(myModule.aNamedExport); // Or named exports work here!
}
main();

It’s clunky, but it’s the only way. This makes refactoring a large codebase from CommonJS to ESM a non-trivial, all-or-nothing effort, which is exactly why so much of the ecosystem is still in CommonJS.