Packaging Node.js Libraries the Right Way: ESM, CommonJS, and Bundlers in 2025

Why Packaging Still Breaks in 2025?

Despite being two decades into Node.js, publishing a library that works across CommonJS (CJS), ECMAScript Modules (ESM), TypeScript, and bundlers is still confusing.

Why? Because:

  • Node.js evolved from CJS to ESM gradually

  • Bundlers (Vite, Webpack, Rollup) behave differently

  • Developers mix TS, .mjs, .cjs, and .js in weird ways

  • Package consumers have wildly different expectations

So let’s break it down - cleanly and practically - to ship libraries that don’t break.

CommonJS vs ESM: Know the Difference

FeatureCommonJSECMAScript Modules (ESM)
Import syntaxrequire()import / export
Export syntaxmodule.exports / exportsexport default / export
File extension.js (default).mjs or "type": "module"
SynchronousYesNo (top-level await allowed)
Used inLegacy Node.js, most toolingModern projects, Deno, browser-native

Interop Problems:

  • ESM can't require() CJS modules directly if they use module.exports

  • CJS can't use import unless you compile it

  • Some bundlers resolve "main" and "module" fields inconsistently

package.json Fields That Matter

Your package.json tells the outside world how to load your code. Here's what matters:

{
  "name": "my-lib",
  "main": "dist/index.cjs",      // CommonJS entry
  "module": "dist/index.esm.js", // ESM entry for bundlers
  "exports": {
    ".": {
      "require": "./dist/index.cjs",
      "import": "./dist/index.esm.js"
    }
  },
  "types": "dist/index.d.ts"
}

Field Breakdown:

  • "main": default for Node (CommonJS)

  • "module": used by bundlers like Rollup, Vite (ESM)

  • "exports": modern standard to declare per-import conditions

  • "types": entry point for TypeScript declarations

Dual Publishing: Support CJS + ESM

If you want your library to work in both ecosystems, publish both builds.

πŸ“‚ Example:

/dist
  β”œβ”€β”€ index.cjs      ← CommonJS
  β”œβ”€β”€ index.esm.js   ← ES Module
  β”œβ”€β”€ index.d.ts     ← TypeScript types

Use a bundler like tsup, rollup, or unbuild to output both formats cleanly:

tsup src/index.ts --format cjs,esm --dts

Should You Pre-Bundle?

βœ… Pre-bundling is helpful when:

  • You ship multiple files

  • You use dependencies that might break interop

  • You want fast cold-starts or browser builds

❌ Don’t pre-bundle if:

  • Your library is dead simple (1 file)

  • You want tree-shaking to be fully consumer-controlled

πŸ‘‰ If unsure, use tsup β€” it’s fast, ESM-aware, and minimal config.

Testing Your Package as a Consumer

Test it as your users would:

  • Import into ESM-only projects

  • Require from legacy CommonJS

  • Build with vite, webpack, rollup, and even node --experimental-loader

Use real-world tools like:

npm init vite@latest
npm i your-lib
# Try importing and running

Common Pitfalls

  • ❌ Top-level await in ESM without proper Node flag

  • ❌ Missing "exports" field breaks bundlers

  • ❌ Default exports in CJS confuse ESM importers

  • ❌ Mixing .js, .ts, .mjs, .cjs without clear build strategy

Bonus: When to Use .cjs and .mjs

If your package.json does not specify "type": "module":

  • Use .cjs for CommonJS

  • Use .mjs for ESM

If it does specify:

"type": "module"

Then:

  • Use .js for ESM

  • Use .cjs for legacy interop (only when needed)

Packaging Workflow

Summary: Ship Libraries That Just Work

What to doWhy it matters
Build both CJS + ESMMaximum compatibility
Use exports in package.jsonPrecise interop, future-proofing
Include .d.ts typesTypeScript + IntelliSense friendly
Test across environmentsNo surprises for consumers

Want Examples or a Minimal Boilerplate?

I’ve skipped code/config samples here intentionally.

Got questions or stuck on a setup? Drop them in the comments β€” happy to help with real-world fixes.

0
Subscribe to my newsletter

Read articles from Faiz Ahmed Farooqui directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Faiz Ahmed Farooqui
Faiz Ahmed Farooqui

Principal Technical Consultant at GeekyAnts. Bootstrapping our own Data Centre services. I lead the development and management of innovative software products and frameworks at GeekyAnts, leveraging a wide range of technologies including OpenStack, Postgres, MySQL, GraphQL, Docker, Redis, API Gateway, Dapr, NodeJS, NextJS, and Laravel (PHP). With over 9 years of hands-on experience, I specialize in agile software development, CI/CD implementation, security, scaling, design, architecture, and cloud infrastructure. My expertise extends to Metal as a Service (MaaS), Unattended OS Installation, OpenStack Cloud, Data Centre Automation & Management, and proficiency in utilizing tools like OpenNebula, Firecracker, FirecrackerContainerD, Qemu, and OpenVSwitch. I guide and mentor a team of engineers, ensuring we meet our goals while fostering strong relationships with internal and external stakeholders. I contribute to various open-source projects on GitHub and share industry and technology insights on my blog at blog.faizahmed.in. I hold an Engineer's Degree in Computer Science and Engineering from Raj Kumar Goel Engineering College and have multiple relevant certifications showcased on my LinkedIn skill badges.