Future-Proofing JavaScript with ESM and CJS Compatibility Techniques
JavaScriptâs module system is like Git: powerful, confusing, and somehow still a daily source of pain.
If youâve tried publishing an NPM package lately, youâve probably wrestled with ECMAScript Modules (ESM) and CommonJS (CJS). Maybe youâve added "type": "module" to your package.json and watched half your consumers scream. Maybe you didnât, and now tree-shaking doesnât work. Either way, itâs a mess.
Letâs clean it up.
This guide walks through how to build dual-compatible JavaScript packages â ones that work whether theyâre require()âd or importâed, without breaking your CI pipeline or sacrificing modern best practices.
Why You Should Care (Even as a DevOps Engineer)
You might think this is a frontend problem. Itâs not. If youâre building CLI tools, Dockerized apps, Lambda functions, or microservices in Node.js â module format matters.
Build tooling expects ESM. Legacy code expects CJS. And your job is to make sure they both get what they want without the whole thing collapsing like a badly written monorepo.
The ESM vs. CJS TL;DR
Letâs not do a full history lesson. Hereâs what you actually need to know:
| Format | ESM | CJS |
|---|---|---|
| Syntax | import/export | require/module.exports |
| Node.js | Default in .mjs or "type": "module" | Default in .js or "type": "commonjs" |
| Pros | Native in browsers, tree-shaking, async imports | Ubiquitous, works everywhere |
| Cons | Canât require() it | Canât import it (without wrappers) |
And no, you canât just slap .mjs on everything and hope it works. Letâs do it right.
Rule #1: Donât Use "type": "module" in Shared Packages
If your library sets "type": "module" in package.json, youâre locking it into ESM-only land.
That means anyone using CJS canât touch it without some bundler gymnastics. Not cool.
Instead, define dual entry points â let the consuming app decide what it wants.
Example: Dual-Compatible package.json
Hereâs the clean, working config I use in real-world packages:
{ "name": "example-library", "version": "1.0.0", "description": "Dual ESM and CJS compatible library", "main": "dist/index.cjs", "module": "dist/index.mjs", "exports": { ".": { "require": "./dist/index.cjs", "import": "./dist/index.mjs" } }, "keywords": ["esm", "cjs", "npm", "compatibility"], "license": "MIT"}Why this works
main: Used by CJS consumers and legacy bundlersmodule: Used by modern bundlers like Vite, Webpack (for ESM)exports: Official Node.js way to define conditional entry points
â This setup lets both
require()andimportwork cleanly without surprises.
Common Mistakes
- Mixing CJS and ESM in the same file: Just donât. Keep them separate.
- Forgetting
.cjsand.mjsextensions: Node cares. A lot. - Assuming bundlers will âjust handle itâ: Spoiler â they wonât.
Build Setup for Both Formats
Use a bundler like rollup or tsup to compile both module types. Example config:
tsup src/index.ts \ --format cjs,esm \ --dts \ --out-dir distThat gives you dist/index.cjs, dist/index.mjs, and dist/index.d.ts.
Bundle once, support both â no drama.
Docker and DevOps Considerations
If youâre shipping Node apps in Docker â and you should be â test both module formats inside containers. Iâve seen countless CI pipelines break because they worked locally but failed when node inside Alpine couldnât parse the wrong module format.
Hereâs what I recommend:
-
Use
node:18-alpineas your base image (or20, if youâre brave). -
Validate both formats in your CI pipeline:
Terminal window node -e "require('./dist/index.cjs')"node --input-type=module -e "import('./dist/index.mjs')" -
Lock your build environment with
package-lock.jsonand exact versions.
Consistency is king. And in Docker, inconsistency kills.
Real-World Example: CLI Tool Distribution
We built a small CLI tool used across multiple dev teams. Some integrated it via require(), others imported it as an ESM module in their Vite-powered setups.
Instead of picking one and making half the users mad, we went dual-mode. Hereâs what worked:
- Split output with
tsup - Defined conditional exports
- Wrote one internal API, wrapped with two interfaces (CJS and ESM)
Result? One package, two module styles, zero complaints.
Takeaway
Supporting both ESM and CJS isnât just about compatibility â itâs about longevity. The Node.js ecosystem isnât switching overnight. You want your package to work today, and still work five years from now.
So build smart. Support both. And donât make your users fight the module loader.
VERDICT & INTEL
- Public Doctrine: Executors debate the hype. Architects calculate the blast radius. Study the visual doctrine on YouTube.
- The Private Order: Stop reacting to the market. Gain access to executive blueprints, architectural protocols, and unfiltered signals. Access the Vault.
Vladimir Mikhalev
Field CTO  ¡ Docker Captain  ¡ IBM Champion  ¡ AWS Community Builder