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()
andimport
work cleanly without surprises.
Common Mistakes
- Mixing CJS and ESM in the same file: Just don’t. Keep them separate.
- Forgetting
.cjs
and.mjs
extensions: 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 dist
That 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-alpine
as 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.json
and 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.
Patreon Exclusives
🏆 Join my Patreon and dive deep into the world of Docker and DevOps with exclusive content tailored for IT enthusiasts and professionals. As your experienced guide, I offer a range of membership tiers designed to suit everyone from newbies to IT experts.
Tools I Personally Trust
If you’re building things, breaking things, and trying to keep your digital life a little saner (like every good DevOps engineer), these are two tools that I trust and use daily:
🛸 Proton VPN - My shield on the internet. It keeps your Wi-Fi secure, hides your IP, and blocks those creepy trackers. Even if I’m hacking away on free café Wi-Fi, I know I’m safe.
🔑 Proton Pass - My password vault. Proper on-device encryption, 2FA codes, logins, secrets - all mine and only mine. No compromises.
These are partner links - you won’t pay a cent more, but you’ll be supporting DevOps Compass. Thanks a ton - it helps me keep this compass pointing the right way 💜
Gear & Books I Trust
📕 Essential DevOps books
🖥️ Studio streaming & recording kit
📡 Streaming starter kit
Social Channels
🎬 YouTube
🐦 X (Twitter)
🎨 Instagram
🐘 Mastodon
🧵 Threads
🎸 Facebook
🦋 Bluesky
🎥 TikTok
💻 LinkedIn
📣 daily.dev Squad
✈️ Telegram
🐈 GitHub
Community of IT Experts
👾 Discord
Refill My Coffee Supplies
💖 PayPal
🏆 Patreon
🥤 BuyMeaCoffee
🍪 Ko-fi
💎 GitHub
⚡ Telegram Boost
🌟 Bitcoin (BTC): bc1q2fq0k2lvdythdrj4ep20metjwnjuf7wccpckxc
🔹 Ethereum (ETH): 0x76C936F9366Fad39769CA5285b0Af1d975adacB8
🪙 Binance Coin (BNB): bnb1xnn6gg63lr2dgufngfr0lkq39kz8qltjt2v2g6
💠 Litecoin (LTC): LMGrhx8Jsx73h1pWY9FE8GB46nBytjvz8g
Is this content AI-generated?
No. Every article on this blog is written by me personally, drawing on decades of hands-on IT experience and a genuine passion for technology.
I use AI tools exclusively to help polish grammar and ensure my technical guidance is as clear as possible. However, the core ideas, strategic insights, and step-by-step solutions are entirely my own, born from real-world work.
Because of this human-and-AI partnership, some detection tools might flag this content. You can be confident, though, that the expertise is authentic. My goal is to share road-tested knowledge you can trust.