627 words
3 min read

Future-Proofing JavaScript with ESM and CJS Compatibility Techniques

By · Docker Captain · CNCF Ambassador · 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  ·  CNCF Ambassador  ·  IBM Champion

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
    A Partial Read Is Not a Smaller Fleet: A GitHub API Rate Limit and the Numbers That Lied for an Hour
    DevOps & Cloud · A partial read published as the whole. For an hour the profile said 24 restore scripts across 15 repositories, and the fleet had 73 across 48. Three guards now.
  2. 2
    Restore Drills on a Clean Machine — Measured RTO for 47 Stacks, 19 Seconds to 7 Minutes
    DevOps & Cloud · Restore drills that measure RTO on a machine that never ran the stack, 47 templates weekly. Every same-host restore passed. The first clean one answered 500.
  3. 3
    Mutation Testing for Infrastructure Code — 153 Planted Bugs, Four Tests That Could Not Fail
    DevOps & Cloud · Mutation testing for infrastructure code, done by hand across fifteen repositories. 153 planted bugs, and four green tests that could never have failed.
  4. 4
    Docker Health Checks That Cannot Fail — Seventeen Hours Green
    DevOps & Cloud · A Docker health check whose pattern matches the shell running it can never go red. How to find the checks in your fleet that never failed, and prove each one.

Random Posts

Random
  1. 1
    Install Docker Engine and Docker Compose on Ubuntu Server
    DevOps & Cloud · Learn how to install Docker Engine and Docker Compose on Ubuntu Server. Follow a clear, step-by-step guide to get Docker running quickly and securely.
  2. 2
    Install Bitbucket on Ubuntu Server: PostgreSQL, Apache, Let's Encrypt
    Self-Hosting · Bitbucket on Ubuntu Server step by step: the PostgreSQL database, an Apache reverse proxy with certbot certificates, the installer, and the first login.
  3. 3
    Install Quake 3 Server Using Docker Compose
    Self-Hosting · Deploy a Quake 3 server with Docker Compose on Ubuntu Server. Fast setup, WebGL-ready, browser access via QuakeJS, plus full configuration steps.
  4. 4
    Export Windows Drivers with PowerShell, Then Put Them Back
    SysAdmin & IT Pro · One cmdlet exports every third-party driver from a running Windows or an offline image. Then pnputil reinstalls them, and DISM injects them into install.wim.
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
Updated
2026-06-16
License
CC BY-NC-SA 4.0