Node.js can execute JavaScript through two module systems: ECMAScript modules (ESM) and CommonJS. The filename is one of the signals that selects between them, which is why files such as postcss.config.mjs appear in projects even when most source files still end in .js.
The important detail is that .mjs is not a different JavaScript language. It is an explicit instruction to Node.js: parse and load this file as an ES module.
.mjs and .cjs are explicit module markers
Node.js treats these two extensions unambiguously:
file.mjs -> ES module
file.cjs -> CommonJSA .mjs file is loaded as ESM regardless of the nearest package.json. A .cjs file is loaded as CommonJS regardless of that package’s "type" value.
That makes the extensions useful when one file needs a different module format from the rest of its package.
An ES module can use static import and export syntax:
// math.mjs
export function add(a, b) {
return a + b;
}// app.mjs
import { add } from './math.mjs';
console.log(add(2, 3));A CommonJS file uses the CommonJS bindings instead:
// math.cjs
function add(a, b) {
return a + b;
}
module.exports = { add };// app.cjs
const { add } = require('./math.cjs');
console.log(add(2, 3));The extension tells Node.js which module semantics apply before the file is evaluated.
package.json controls ordinary .js files
Using .mjs everywhere is unnecessary when an entire package uses ESM. Node.js lets package.json define the interpretation of .js files:
{
"type": "module"
}With that setting, .js files in the package are treated as ES modules:
// math.js
export function add(a, b) {
return a + b;
}Without changing the source code, switching the package to:
{
"type": "commonjs"
}makes ordinary .js files CommonJS instead.
The "type" field therefore describes the package’s default module format. The explicit extensions still override that default:
"type": "module"
.js -> ES module
.mjs -> ES module
.cjs -> CommonJS
"type": "commonjs"
.js -> CommonJS
.mjs -> ES module
.cjs -> CommonJSThis is a practical way to keep a mostly ESM project while retaining a legacy CommonJS configuration file, or the reverse.
The nearest package.json creates the boundary
The "type" field is not a repository-wide switch. For a .js file, Node.js looks for the nearest parent package.json that controls that file.
A nested package can therefore use a different default:
project/
├── package.json # "type": "module"
├── src/
│ └── app.js # ES module
└── legacy/
├── package.json # "type": "commonjs"
└── worker.js # CommonJSThis boundary matters in monorepos, test fixtures, build tooling, and embedded packages. Moving a .js file across a package boundary can change its module interpretation even when the file itself is unchanged.
Files named .mjs or .cjs do not depend on that lookup for their module format.
Ambiguous .js files can trigger syntax detection
A common shorthand says that .js means CommonJS unless package.json contains "type": "module". That description is incomplete on current Node.js versions.
When a .js file has no explicit module marker, Node.js can inspect ambiguous source text for syntax that only parses as ESM. Static import, export, import.meta, and top-level await are examples of syntax that can make the runtime classify the file as an ES module.
For example:
// ambiguous.js
export const port = 3000;If there is no controlling "type" field, modern Node.js can identify the ESM-only syntax and load the file accordingly.
Relying on detection is less explicit than declaring the package type. Package authors are better served by setting "type": "module" or "type": "commonjs" so the intended format is visible to Node.js and surrounding tooling.
Dynamic import does not make a file ESM
The expression import() works in both module systems. Its presence alone does not force a CommonJS file to become an ES module.
This CommonJS file is valid:
// loader.cjs
async function loadPlugin() {
const plugin = await import('./plugin.mjs');
return plugin;
}
module.exports = { loadPlugin };That distinction matters when reading source code. Static import ... from ... is ESM syntax; the dynamic import() expression can be called from CommonJS as well.
Relative ESM imports use explicit file names
ESM resolution is stricter than the classic CommonJS require() behavior for relative paths. A relative ESM import normally includes the file extension:
import { add } from './math.js';Writing only:
import { add } from './math';does not ask Node.js’s ESM resolver to try ./math.js, ./math.json, and other candidates the way traditional CommonJS resolution can.
This is one reason migrations from CommonJS to ESM sometimes fail even after replacing require() with import: module syntax and module resolution are separate parts of the change.
.mjs is useful for local exceptions
For a new Node.js application that is entirely ESM, a package-level declaration is usually cleaner:
{
"type": "module"
}Source files can then keep the conventional .js extension.
The .mjs extension remains useful when the module format needs to be explicit at the file boundary. Typical cases include a standalone script outside a package configuration, an ESM configuration file inside a CommonJS package, or a small interoperability boundary during migration.
The inverse role belongs to .cjs: it marks a CommonJS file even inside a package whose default is ESM.
The extension is part of runtime semantics
The difference between .mjs, .cjs, and .js is not cosmetic in Node.js. The extension can determine the parser goal, available module bindings, and resolution path used by the runtime.
A useful model is:
.mjs -> always ESM
.cjs -> always CommonJS
.js -> package.json "type" first; ambiguous input may be syntax-detectedThat model also explains why renaming a file can change behavior without changing a single line of JavaScript. In Node.js, the filename and package boundary are part of the module contract.