Future-Proofing JavaScript with ESM and CJS Compatibility Techniques
By Vladimir Mikhalev · Solutions Architect · Docker Captain · IBM Champion
JavaScript’s module system is a lot like Git. Powerful, confusing, and still a daily source of pain years after you thought you’d figured it out.
Published an NPM package lately? Then you’ve fought this. You add "type": "module" to your package.json, and half your consumers start screaming. Or you leave it off, and now tree-shaking is dead. There’s no clean default. It’s a mess either way.
So let’s fix it.
This guide is about building dual-compatible JavaScript packages. Packages that work whether someone require()s them or imports them, without blowing up your CI pipeline and without giving up modern tooling.
Why You Should Care (Even as a DevOps Engineer)
You probably think this is a frontend problem. It isn’t. CLI tools, Dockerized apps, Lambda functions, microservices in Node.js: for all of them, module format matters.
Build tooling wants ESM. Legacy code wants CJS. Your job is to feed both without the whole thing folding in on itself like a badly wired monorepo.
The ESM vs. CJS TL;DR
I’ll skip the history lesson. Here’s what actually matters in practice:
| 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) |
So no, slapping .mjs on everything and praying is not a strategy. Do it properly.
Rule #1: Don’t Use "type": "module" in Shared Packages
Set "type": "module" in a library’s package.json, and you’ve just locked that library into ESM-only territory.
Now every CJS consumer is stuck doing bundler gymnastics to use it. Bad trade.
Do this instead. Define dual entry points and let the consuming app pick what it wants.
Example: Dual-Compatible package.json
This is the config I actually ship in real packages. Clean, and it works:
{ "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
Reach for a bundler like rollup or tsup and compile both module types from one source. Here’s a 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. One build, both formats, no drama.
Docker and DevOps Considerations
Shipping Node apps in Docker? You should be. And when you do, test both module formats inside the container. I’ve watched far too many CI pipelines pass locally and then fall over the moment node inside Alpine choked on the wrong module format.
Here’s what I do:
-
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. In Docker, the small inconsistencies are the ones that kill you.
Real-World Example: CLI Tool Distribution
We built a small CLI tool that ended up spreading across several dev teams. Some pulled it in with require(). Others imported it as an ESM module in their Vite setups.
We could have picked one and annoyed half of them. We went dual-mode instead. Here’s what worked:
- Split output with
tsup - Defined conditional exports
- Wrote one internal API, wrapped with two interfaces (CJS and ESM)
The result? One package, two module styles, nobody complaining.
Takeaway
Supporting both ESM and CJS is about more than compatibility. It’s about longevity. The Node.js ecosystem is not flipping to ESM-only overnight, and you want your package to work today, and still work five years from now.
So build smart. Support both. Don’t make your users fight the module loader.
The Verdict
Inconvenient truths about shipping in the AI era
Container security, platform engineering, and the agentic shift — tested in production, argued without the hype. The verdict reaches your inbox the moment there's one worth sending.
Related Posts
- 1Docker supply chain hardening — from Scout D to OpenSSF 7.8 on a 730K-pull imageDevOps & Cloud · How I hardened a 730K-pull public Docker image from Scout grade D to OpenSSF Scorecard 7.8. Multi-stage build, cosign signing, SLSA provenance, non-root default, and the incident that changed how I ship attestations.
- 2Cloudflare Web Analytics on Astro — Why Removing GA4 Unlocked Lighthouse 100DevOps & Cloud · How removing Google Analytics 4 from an Astro site unlocked Lighthouse 100, why Cloudflare Web Analytics replaced it, and what the tradeoffs actually cost.
- 3Platform Engineering — The Complete, Practical Guide to Building Internal Developer Platforms That ScaleDevOps & Cloud · A deep, practical guide to Platform Engineering. Learn how to build internal developer platforms, golden paths, GitOps workflows, and scalable cloud foundations.
- 4Amazon Q vs DevOps Chaos — Can This AI Fix AWS Faster Than You?DevOps & Cloud · Fix AWS issues faster with Amazon Q, the AI assistant built for DevOps. Real-world examples, limitations, and how it compares to ChatGPT.
Random Posts
- 1Install CentOS 7 MinimalSysAdmin & IT Pro · Step-by-step guide to install CentOS 7 Minimal with screenshots. Learn how to configure language, network, partitions, and users for a clean Linux setup.
- 2I Tested an AI Agent on My Live Systems. Here Is the Blast Radius Assessment Every Engineer Is Skipping.Opinion & Culture · Everyone is buying Mac Minis and installing AI agents. I tested one in isolation. Here is the architectural framework for deployment that the Instagram hype does not include.
- 3Install Ubuntu Server 20.04 LTSSysAdmin & IT Pro · Comprehensive guide to install Ubuntu Server 20.04 LTS. Covers disk setup, network configuration, OpenSSH, and user creation for reliable Linux server deployment.
- 4Install OTRS on Ubuntu ServerSysAdmin & IT Pro · Comprehensive guide to installing OTRS Community Edition on Ubuntu Server. Learn to configure PostgreSQL, Apache, SSL with Let's Encrypt, and launch OTRS securely.