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

Table of contents
- Why Packaging Still Breaks in 2025?
- CommonJS vs ESM: Know the Difference
- package.json Fields That Matter
- Dual Publishing: Support CJS + ESM
- Should You Pre-Bundle?
- Testing Your Package as a Consumer
- Common Pitfalls
- Bonus: When to Use .cjs and .mjs
- Packaging Workflow
- Summary: Ship Libraries That Just Work
- Want Examples or a Minimal Boilerplate?

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 waysPackage 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
Feature | CommonJS | ECMAScript Modules (ESM) |
Import syntax | require() | import / export |
Export syntax | module.exports / exports | export default / export |
File extension | .js (default) | .mjs or "type": "module" |
Synchronous | Yes | No (top-level await allowed) |
Used in | Legacy Node.js, most tooling | Modern projects, Deno, browser-native |
Interop Problems:
ESM can't
require()
CJS modules directly if they usemodule.exports
CJS can't use
import
unless you compile itSome 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 evennode --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 CommonJSUse
.mjs
for ESM
If it does specify:
"type": "module"
Then:
Use
.js
for ESMUse
.cjs
for legacy interop (only when needed)
Packaging Workflow
Summary: Ship Libraries That Just Work
What to do | Why it matters |
Build both CJS + ESM | Maximum compatibility |
Use exports in package.json | Precise interop, future-proofing |
Include .d.ts types | TypeScript + IntelliSense friendly |
Test across environments | No 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.
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.