Skip to main content

Command Palette

Search for a command to run...

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

A practical guide for library authors: How to support both module systems, avoid interop headaches, and publish packages that just work.

Updated
3 min read
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.

Scaling JavaScript & Node.js

Part 5 of 16

Learn how to build scalable, high-performance applications in JavaScript and Node.js — this series breaks down real-world solutions for handling large datasets, improving API speed, and writing production-grade backend code.

Up next

JWT vs PASETO vs Session-Based Auth

Secure Token Systems: What to Use and When