Skip to main content
Zubin Khavarian
Pen-and-ink watercolor of wooden modules with matching joints assembling into a structure, leftover mismatched pieces to the side.

Use ESM

· Last updated

Use ESM.

That is the whole article, with some scars around it. JavaScript has a standard module system now. Browsers run it. Node runs it. The other names on this topic are either Node’s original require(), or museum pieces from the years before browsers had modules at all.

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

What is a module?

A module is a file with a boundary. What you export is public. Everything else is local.

Without that boundary, every file shares one global scope. Naming collisions, load order bugs, “who mutated config.” Modules are how we stopped doing that.

ESM

ECMAScript modules 🔗 are the language’s module system. export to make something public. import to take 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 specifier is not a suggestion. In Node ESM, relative imports need the extension the runtime will load. Bundlers are looser. Node is not.

ESM is static. The import graph can be determined without running the file. That is what bundlers use for tree-shaking. Node itself does not tree-shake at runtime. Smaller bundles come from the bundler, not from the import keyword magically deleting code.

Prefer named exports. Default exports work. Named exports rename with the compiler, show up in autocomplete, and do not become import thing from vs 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 is the part that bites you.

Node has two module systems. Which one a file is depends on the extension and the nearest package.json. From the package docs 🔗:

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

The fallback is syntax detection 🔗. If an unmarked .js file contains import / export / import.meta, Node may retry it as ESM. That path is still a release candidate. It also costs a parse.

Do not rely on it.

Put "type": "module" in package.json. Use .cjs for the rare file that must stay CommonJS. Use .mjs if you need an ESM file inside a CommonJS package. Package authors should set "type" even when every file is CommonJS. Node says 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. Synchronous. No browser runtime.

// 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 is not gone. Unmarked .js files are still CommonJS. A huge amount of npm still publishes it. require() still does extension searching and index.js folders, which ESM will not do for you.

The old line was that CommonJS cannot load ESM, because require() is synchronous and ESM can have top-level await. That line is now half true.

On current Node, require() can load an ES module that has no top-level await. The result is the module namespace object, with the default export on .default. If the graph uses top-level await, require() throws ERR_REQUIRE_ASYNC_MODULE and you need import().

New code should still be ESM. You do not need to rewrite a working CommonJS app this afternoon. You also do not need a dual CJS/ESM publish so a CommonJS consumer can require() you, unless you still target runtimes from before this worked, or you use top-level await.

AMD and UMD

You can stop reading this section if you are not maintaining a 2014-era library.

AMD (Asynchronous Module Definition) was how browsers loaded modules before they had modules. RequireJS 🔗 popularized define(). The point was async loading over HTTP, because CommonJS require() is blocking and browsers hate that.

UMD (Universal Module Definition) was a wrapper that sniffed the environment and behaved like AMD, CommonJS, or a global. Library authors used it so one file worked everywhere.

Neither is a choice you should make in a new project. Native ESM in browsers, plus bundlers, plus Node’s exports map, replaced both.

If you still see define(['dep'], function (dep) { ... }) in a dependency, that is a fossil. You do not need to produce more of them.

What about TypeScript?

TypeScript does not pick a module system for you. It models the one your runtime or bundler will use.

The handbook’s rule 🔗 is simple.

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

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

App that Node runs from tsc output:

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

Plus "type": "module" in package.json if you want ESM emit. nodenext will then demand the .js extension on relative imports, matching Node.

.mts is ESM TypeScript. .cts is CommonJS TypeScript. Same idea as .mjs / .cjs.

Do not set "moduleResolution": "bundler" on a library you publish for Node. Extensionless imports typecheck, then blow up at runtime with ERR_MODULE_NOT_FOUND. The handbook’s example is exactly that.

What should you not do?

I have done all of these.

Leave "type" off and hope. Node will treat .js as CommonJS until it does not. Set the field.

Write ESM relative imports without extensions, then run them in Node. Works in Vite. Dies in node dist/index.js. Either use nodenext so TypeScript catches it, or do not run unbundled ESM in Node.

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

Start a new browser app on AMD. No.

Publish a library on "moduleResolution": "bundler" because the imports look cleaner. Your .d.ts files inherit those specifiers. Consumers on nodenext then inherit your mistake.

I’d start with "type": "module" and ESM. Touch CommonJS when a file or a dependency forces you to. Never touch AMD.

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, Principal Front-End EngineerWritten by