Skip to main content
Zubin KhavarianZubin Khavarian

Use ESM

· Last updated · 6 min read · #javascript #typescript

Editorial illustration of wooden modules with matching joints assembling into a structure, leftover mismatched pieces to the side.

Use ESM.

That’s the whole article; everything below is the scar tissue around it. JavaScript has had a standard module system for years now, and both browsers and Node run it. The other names you’ll see on this topic are either Node’s original require() or museum pieces from before browsers had modules at all.

If you’re starting a project today, you want import and export, "type": "module" in package.json, and a bundler only if you’re shipping to the browser.

What is a module?

A module is a file with a boundary. Whatever you export is public, and everything else stays local to the file.

Without that boundary, every file shares one global scope, and you get naming collisions, load-order bugs and long afternoons asking who mutated config. Modules are how we stopped doing that.

ESM

ECMAScript modules (opens in a new tab) are the language’s own module system. You export to make something public and import to use it.

// math.js
export function add(a, b) {
  return a + b;
}

export function subtract(a, b) {
  return a - b;
}
// main.js
import { add, subtract } from "./math.js";

console.log(add(5, 3));
console.log(subtract(10, 4));

The .js on that import isn’t decoration. In Node’s ESM, relative imports need the exact file extension the runtime will load. Bundlers are more forgiving; Node isn’t.

ESM is also static, which means the whole import graph can be worked out without running any code. That’s what bundlers use for tree-shaking. Node itself doesn’t tree-shake anything at runtime, so smaller bundles come from your bundler, not from the import keyword.

While you’re at it, prefer named exports. Default exports work, but named exports rename with the compiler, show up in autocomplete, and don’t turn into import thing from versus import { thing } from depending on who wrote the file.

How Node decides

This is the part the old “ESM vs CommonJS” explainers skip, and it’s the part that actually bites.

Node supports both module systems, and which one a file uses depends on its extension and the nearest package.json. From the package docs (opens in a new tab):

MarkerFormat
.mjsAlways ESM
.cjsAlways CommonJS
"type": "module" and .jsESM
"type": "commonjs" and .jsCommonJS
No "type" field and .jsCommonJS, with a fallback

Or, as a picture:

extension?ESMpackage.json “type”?CommonJSESMCommonJSCommonJS.mjs.js.cjs"module""commonjs"missingmay be retried as ESM
The extension wins first. For .js, the nearest package.json decides, and leaving “type” out means CommonJS.

That fallback is syntax detection (opens in a new tab). If an unmarked .js file contains import, export or import.meta, Node may retry it as ESM. It’s a guess, it costs an extra parse, and it’s easy to be surprised by, so don’t rely on it.

Put "type": "module" in your package.json. Use .cjs for the rare file that has to stay CommonJS, and .mjs if you need an ESM file inside a CommonJS package. Package authors should set "type" even when every file is CommonJS; the Node docs say so, because the default is a trap.

{
  "name": "my-app",
  "type": "module"
}

That one field is the difference between import { add } from "./math.js" working and SyntaxError: Unexpected token 'export'.

CommonJS

CommonJS is what Node shipped with: require() and module.exports, loaded synchronously, with no browser support.

// utils.js
exports.capitalize = function (str) {
  return str.charAt(0).toUpperCase() + str.slice(1);
};
// main.js
const utils = require("./utils");

console.log(utils.capitalize("hello"));

It isn’t gone. Unmarked .js files are still CommonJS, a huge amount of npm still publishes it, and require() still does the extension searching and index.js folder lookups that ESM won’t do for you.

The old rule was that CommonJS can’t load ESM, because require() is synchronous and ESM can use top-level await. That’s only half true now.

On current Node, require() can load an ES module as long as it has no top-level await. You get back the module namespace object, with the default export on .default. If anything in the graph uses top-level await, require() throws ERR_REQUIRE_ASYNC_MODULE and you need import() instead.

New code should still be ESM, but you don’t need to rewrite a working CommonJS app this afternoon. You also don’t need a dual CJS/ESM publish just so CommonJS consumers can require() you, unless you support older runtimes where this didn’t work or you use top-level await.

AMD and UMD

You can skip this section unless you maintain a library from around 2014.

AMD (Asynchronous Module Definition) was how browsers loaded modules before they had modules. RequireJS (opens in a new tab) popularized define(), and the whole point was loading over HTTP asynchronously, because CommonJS’s require() blocks and browsers hate blocking.

UMD (Universal Module Definition) was a wrapper that sniffed its environment and behaved like AMD, CommonJS or a global, so library authors could ship one file that worked everywhere.

Neither is a choice to make in a new project. Native ESM in browsers, bundlers, and Node’s exports map replaced both. If you still see define(['dep'], function (dep) { ... }) in a dependency, that’s a fossil, and there’s no need to make more.

What about TypeScript?

TypeScript doesn’t pick a module system for you; it models the one your runtime or bundler will use. The handbook’s rule (opens in a new tab) is simple.

An app that a bundler ships (Vite, webpack, esbuild, Bun, tsx):

{
  "compilerOptions": {
    "module": "esnext",
    "moduleResolution": "bundler"
  }
}

An app that Node runs from tsc output:

{
  "compilerOptions": {
    "module": "nodenext"
  }
}

Add "type": "module" to package.json if you want ESM output. nodenext will then insist on the .js extension in relative imports, exactly like Node does.

.mts is ESM TypeScript and .cts is CommonJS TypeScript, the same idea as .mjs and .cjs.

Don’t set "moduleResolution": "bundler" on a library you publish for Node. Extensionless imports will typecheck and then blow up at runtime with ERR_MODULE_NOT_FOUND. That’s precisely the example the handbook uses.

What should you not do?

I’ve done every one of these.

Leave "type" out and hope. Node treats .js as CommonJS until, one day, it doesn’t. Set the field.

Write ESM imports without extensions and then run them in Node. It works in Vite and dies in node dist/index.js. Either use nodenext so TypeScript catches it, or don’t run unbundled ESM in Node.

Mix import and require in one file. One file, one system. If you really need require from ESM, createRequire(import.meta.url) from node:module is the supported escape hatch.

Start a new browser app on AMD. Please don’t.

Publish a library with "moduleResolution": "bundler" because the imports look cleaner. Your .d.ts files inherit those import paths, and consumers on nodenext inherit your mistake.

Start with "type": "module" and ESM. Reach for CommonJS only when a file or a dependency forces you to, and leave AMD in the museum.

Stay in touch

Don't miss out on new posts or project updates. Hit me up on X for updates, queries, or some good ol' tech talk.

Follow @zkmake
Zubin Khavarian, Software EngineerWritten by