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
- 1Terraform MCP server GA: the Apply Gate your auditor will ask aboutDevOps & Cloud · HashiCorp's Terraform MCP server is GA and IBM Bob can write production IaC. ENABLE_TF_OPERATIONS separates a safe assistant from autonomous apply.
- 2Docker 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.
- 3Cloudflare 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.
- 4Platform 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.
Random Posts
- 1Install Firmware for Kernel Drivers on UbuntuSysAdmin & IT Pro · Learn how to install firmware for kernel drivers on Ubuntu using .deb packages to ensure hardware compatibility and system stability.
- 2DevOps and Platform Engineering DynamicsDevOps & Cloud · Explore the comprehensive impact of DevOps and Platform Engineering on software development, detailing key strategies, technological innovations, and future trends shaping the industry.
- 3Install Foreman on Ubuntu ServerSysAdmin & IT Pro · Learn how to install Foreman on Ubuntu Server to manage, provision, and monitor infrastructure with Puppet integration in a few simple steps.
- 4Install GitLab Using Docker ComposeSelf-Hosting · Step-by-step guide to install GitLab using Docker Compose and Traefik with Let's Encrypt SSL. Host your own GitLab server with CI/CD and SSH access.