618 words
3 min read

Future-Proofing JavaScript with ESM and CJS Compatibility Techniques

By · Solutions Architect · Docker Captain · IBM Champion
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:

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)

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 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#

Use a bundler like rollup or tsup to compile both module types. Example 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. 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:

  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. 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.


Vladimir Mikhalev

Docker Captain  ·  IBM Champion  ·  AWS Community Builder

The Verdict — production-tested analysis on YouTube.

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 Multicraft on Ubuntu Server
    SysAdmin & IT Pro · Step-by-step guide to installing Multicraft on Ubuntu Server. Set up a secure Minecraft server hosting panel with Apache, MySQL, and Let's Encrypt SSL.
  2. 2
    Install GLPI Using Docker Compose
    Self-Hosting · Learn how to install GLPI using Docker Compose with Traefik and Let's Encrypt. Set up your open-source IT asset management and service desk system step-by-step.
  3. 3
    Install Lync Server 2010
    SysAdmin & IT Pro · Learn how to install Lync Server 2010 step-by-step on Windows Server 2008 R2. Set up unified communications without failover, including DNS and certificates.
  4. 4
    Install Bitbucket Using Docker Compose
    Self-Hosting · Learn how to install Bitbucket using Docker Compose and Traefik on your server. Step-by-step guide with HTTPS setup and admin configuration for Git hosting.
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