202603101452-mjs-file-extension

🎯 Core Idea

The .mjs file extension is a JavaScript filename convention that explicitly marks a file as an ECMAScript module. In practice, its main job is not to change the language itself, but to remove ambiguity about how a runtime or tool should interpret the file. In the modern JavaScript ecosystem, the biggest ambiguity is usually whether a file should be treated as a CommonJS module or as an ES module. The .mjs extension exists to make that decision explicit.

This matters most in Node.js, where JavaScript has lived through a long transition from the older CommonJS module system to the standardized ES module system. Node historically treated .js as CommonJS by default, while browsers standardized on import and export as the native module syntax. As ES modules became part of mainstream JavaScript, the ecosystem needed a way to say, without depending on package-level context, “this file is definitely ESM.” .mjs became that marker. The corresponding .cjs extension plays the same role for explicit CommonJS.

The practical meaning of .mjs is therefore narrower and more operational than many people think. It does not represent a different language variant. A .mjs file is still JavaScript. The real difference is how the runtime loads it, resolves imports, exposes module scope, and interprets syntax such as import, export, import.meta, and top-level await. In Node, .mjs is always treated as an ES module regardless of the surrounding package configuration. That is the key property that makes it useful.

In everyday engineering, .mjs is most helpful when a project needs explicitness at file level. For example, a mostly CommonJS codebase may still want one build script, config script, or entry file to run as ESM. A package may want to publish mixed module outputs without relying entirely on the package-wide "type" field. Tooling and migration workflows also benefit from .mjs, because it makes module format visible in the filename rather than hidden in package metadata. The extension is less about aesthetics and more about control.

🌲 Branching Questions

➡ What does .mjs mean in practice, and how is it different from .js?

In practice, .mjs means “treat this file as an ES module.” That is the operational definition that matters. The contents are still JavaScript, but the runtime loads the file using ESM rules rather than CommonJS rules. In Node.js, that means import and export are native syntax, require and module.exports are not available in the usual CommonJS way, and the file participates in the ES module loader rather than the legacy CommonJS loader.

By contrast, .js is context-dependent. In browsers, a .js file can be used as a module if it is loaded through <script type="module">, but the extension itself does not force that interpretation. In Node.js, a .js file can be treated either as CommonJS or as ESM depending on package configuration, especially the nearest package.json and its "type" field. That is why .mjs exists: it gives a file-level answer where .js may depend on surrounding context.

This difference is important during maintenance and migration. If a developer sees something.mjs, the intended module system is obvious. If they see something.js, they may have to inspect the package boundary, bundler rules, or runtime behavior before they know how the file will execute. In a small codebase this ambiguity may be tolerable; in a large or mixed codebase it becomes a real source of friction.

➡ When should a project use .mjs instead of relying on "type": "module"?

A project should use .mjs when it wants file-level explicitness or when only part of the codebase should run as ESM. In Node.js, setting "type": "module" in package.json makes ordinary .js files behave as ES modules within that package boundary. That is a package-wide choice, which is often the cleanest option for a new ESM-first codebase. But it is not always the best option for mixed environments.

.mjs becomes useful when a project is still mostly CommonJS, but one or two files need to opt into ESM. Typical examples include migration scripts, build scripts, small tools, or package entry points that need to use native import syntax or interoperate with ESM-only dependencies. It also helps in repositories where module format should be visible from the filename alone, without asking readers to inspect package metadata first.

There is also a publishing angle. Some packages intentionally produce both ESM and CommonJS outputs. In those cases, .mjs and .cjs can make the distribution easier to reason about. They are not the only way to publish dual builds, but they are one of the clearest ways to encode module format directly into emitted files.

The tradeoff is readability versus consistency. If an entire project is fully ESM, then a package-wide "type": "module" plus normal .js files is often cleaner. If the codebase is mixed, transitional, or tooling-heavy, .mjs is often the more explicit and lower-risk choice.

➡ How do you use .mjs correctly in Node.js?

The most straightforward use is simply to create a file with the .mjs extension and write normal ES module syntax inside it. For example:

import { readFile } from 'node:fs/promises';

const text = await readFile('./package.json', 'utf8');
console.log(text);

Saved as script.mjs, Node will treat that file as ESM even if the package does not declare "type": "module". That means imports follow ESM rules, including explicit file extensions for relative imports. For example, a relative import should look like import './utils.mjs' or import './utils.js', not import './utils' in the style CommonJS developers may be used to.

A second practical rule is to remember what ESM removes or changes relative to CommonJS. In an .mjs file, the usual CommonJS globals such as require, module, exports, __filename, and __dirname are not provided in the same way. Instead, ESM relies on import, export, and import.meta. This is one reason .mjs can surface migration issues quickly: it forces code into the ESM model instead of letting older CommonJS assumptions linger invisibly.

A third rule is to treat .mjs as a runtime signal, not a bundler trick. Many tools can transpile, bundle, or abstract away the distinction, but the extension is most valuable when it reflects real runtime intent. If a file truly needs to execute as ESM in Node, using .mjs is a precise, self-describing choice.

➡ What are the main caveats and ecosystem implications of .mjs?

The main caveat is that .mjs is not a universal best practice; it is a tool for explicitness in an ecosystem that still carries multiple module systems. If a project is already cleanly ESM and uses "type": "module", forcing .mjs everywhere can feel noisy. Many teams prefer to reserve .mjs for mixed or transitional cases rather than for every file.

Another caveat is interoperability. The JavaScript ecosystem still includes packages, examples, tooling, and scripts written with CommonJS assumptions. Once a file becomes .mjs, those assumptions stop working unless adapted. That can affect config files, test runners, loaders, and small helper scripts. Some tools now handle ESM well, but not all historical tooling does so consistently.

There is also a conceptual trap: developers sometimes think .mjs is the ESM equivalent of TypeScript’s .mts, or that it signals a browser-specific format. It does not. It is simply JavaScript marked for ESM treatment. Its strongest meaning is in Node’s loader rules, where .mjs is guaranteed to be interpreted as an ES module and .cjs is guaranteed to be interpreted as CommonJS. Seen that way, .mjs is best understood as a disambiguation mechanism in a multi-module ecosystem, not as a new language dialect.

📚 References