642 words
3 min read

Future-Proofing JavaScript with ESM and CJS Compatibility Techniques

By · Solutions Architect · Docker Captain · IBM Champion
Cover image for the post 'Future-Proofing JavaScript with ESM and CJS Compatibility Techniques'

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:

FormatESMCJS
Syntaximport/exportrequire/module.exports
Node.jsDefault in .mjs or "type": "module"Default in .js or "type": "commonjs"
ProsNative in browsers, tree-shaking, async importsUbiquitous, works everywhere
ConsCan’t require() itCan’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 bundlers
  • module: Used by modern bundlers like Vite, Webpack (for ESM)
  • exports: Official Node.js way to define conditional entry points

✅ This setup lets both require() and import 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#

Reach for a bundler like rollup or tsup and compile both module types from one source. Here’s a config:

Terminal window
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. 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:

  1. Use node:18-alpine as your base image (or 20, if you’re brave).

  2. Validate both formats in your CI pipeline:

    Terminal window
    node -e "require('./dist/index.cjs')"
    node --input-type=module -e "import('./dist/index.mjs')"
  3. Lock your build environment with package-lock.json and 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.


Vladimir Mikhalev

Docker Captain  ·  IBM Champion  ·  AWS Community Builder

The Verdict — production-tested analysis on YouTube.

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

Same category
  1. 1
    Docker supply chain hardening — from Scout D to OpenSSF 7.8 on a 730K-pull image
    DevOps & 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.
  2. 2
    Cloudflare Web Analytics on Astro — Why Removing GA4 Unlocked Lighthouse 100
    DevOps & 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.
  3. 3
    Platform Engineering — The Complete, Practical Guide to Building Internal Developer Platforms That Scale
    DevOps & Cloud · A deep, practical guide to Platform Engineering. Learn how to build internal developer platforms, golden paths, GitOps workflows, and scalable cloud foundations.
  4. 4
    Amazon 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

Random
  1. 1
    Install CentOS 7 Minimal
    SysAdmin & 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.
  2. 2
    I 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.
  3. 3
    Install Ubuntu Server 20.04 LTS
    SysAdmin & 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.
  4. 4
    Install OTRS on Ubuntu Server
    SysAdmin & 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.
Future-Proofing JavaScript with ESM and CJS Compatibility Techniques
https://heyvaldemar.com/future-proofing-javascript-with-esm-and-cjs-compatibility-techniques/
Author
Vladimir Mikhalev
Published
2024-05-08
License
CC BY-NC-SA 4.0