Why Dual Packages Matter
The JavaScript ecosystem currently operates with two module systems:
- CommonJS (CJS) - The traditional Node.js system using require()
- ES Modules (ESM) - The modern standard using import/export
This divide creates real challenges. For example, when popular libraries like chalk
transitioned to ESM-only in version 5, many existing CommonJS projects faced compatibility issues. While ESM is the future, the reality is that numerous production systems still rely on CJS.
The Solution: Dual-Package Support
By building libraries that support both formats, we can:
- Maintain backward compatibility
- Support modern JavaScript workflows
- Reduce ecosystem fragmentation
- Provide a smoother migration path
Here's a straightforward approach to implement dual-package support.
Implementation Guide
Project structure
your-lib/
├── dist/ # Generated output (added to .gitignore)
├── src/ # Source files in TypeScript/ES6
│ ├── utils.ts # Library functionality
│ └── index.ts # Main entry point
├── package.json # Dual-package configuration
├── rollup.config.js # Build setup
├── tsconfig.declarations.json # TS declarations config
└── tsconfig.json # TS base config
TypeScript Support
We use two tsconfig
files for optimal compilation:
Base Config (tsconfig.json
)**
{
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"target": "ESNext",
"module": "Preserve",
"moduleResolution": "bundler",
"rootDir": "src"
},
"include": ["src/**/*.ts"]
}
Key features:
- Handles the main transpilation
- Outputs modern JavaScript (ESM by default)
- Used by Rollup during build
Declarations Config (tsconfig.declarations.json
)
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "dist/types"
}
}
Key features:
- Generates type definitions (.d.ts files)
- Runs separately from main build
- Ensures clean type output without JS files
Build Configuration (rollup.config.js
)
import typescript from '@rollup/plugin-typescript';
export default {
input: 'src/index.ts',
output: [
{
dir: 'dist/esm',
format: 'esm',
entryFileNames: '[name].mjs',
},
{
dir: 'dist/cjs',
format: 'cjs',
entryFileNames: '[name].cjs',
},
],
plugins: [
typescript({
tsconfig: './tsconfig.json',
}),
],
};
Key features:
- Creates separate ESM (.mjs) and CJS (.cjs) builds
- Uses TypeScript plugin for compilation
- Maintains clean output structure
Configure package.json
The package.json
file is crucial for dual-package support. Here are the key configuration aspects:
Module System Configuration:
{
"type": "module",
"main": "dist/cjs/index.cjs",
"module": "dist/esm/index.mjs",
"types": "dist/types/index.d.ts",
"exports": {
".": {
"require": "./dist/cjs/index.cjs",
"import": "./dist/esm/index.mjs",
"types": "./dist/types/index.d.ts"
}
}
}
It's critical to properly separate dependencies for build tools (Rollup, TypeScript, etc.)
devDependencies
:
{
"@rollup/plugin-typescript": "^12.1.2",
"@types/node": "^22.14.1",
"rollup": "^4.40.0",
"tslib": "^2.8.1",
"typescript": "^5.8.3"
}
Use dependencies
only for packages your library actually uses at runtime.
Why this separation matters:
- Installation Efficiency: npm/yarn won't install devDependencies for end users
- Smaller Bundle Size: Prevents unnecessary packages from being included
- Clear Dependency Documentation: Shows what's needed for building vs running
- Security: Reduces potential attack surface in production
Best Practices:
- Only include truly required packages in dependencies
- Keep all build/test tools in
devDependencies
- Specify exact versions (or use
^
) for important compatibility - Run
npm install --production
to test your runtime dependencies
Remember: Well-structured dependencies make your library more reliable and easier to maintain.
For a complete working example, check out this boilerplate project on GitHub:
👉 Dual-Package Library Example
It includes all the configurations discussed, so you can fork it or use it as a reference.
If you found this guide helpful:
⭐ Give it a star on GitHub to support the project!
💬 Leave a comment below with your thoughts or questions.
🔄 Share with others who might benefit from it.
Happy coding, and may your libraries work everywhere! 🚀
With respect,
Evgeny Kalkutin - kalkutin.dev
Top comments (2)
Neat guide! How do you decide when it's finally okay to stop supporting the old system and just use the new one?
Thanks for the great question! While Node.js docs confirm ESM is the standard and CJS is legacy (not deprecated), dual support remains practical. The ecosystem can't transition all dependencies overnight. I'd only consider dropping CJS when Node.js officially deprecates it. Until then, dual packages ensure maximum compatibility with minimal maintenance.
Also, take a look at: this research