Building Universal npm Libraries: Supporting Both CJS and ESM
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.js # Library functionality │ └── index.js # 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": "NodeNext", "moduleResolution": "NodeNext", "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: devDependencies: For build tools (Rollup, TypeScript, etc.) { "@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:

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.js # Library functionality
│ └── index.js # 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": "NodeNext",
"moduleResolution": "NodeNext",
"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:
devDependencies
: For build tools (Rollup, TypeScript, etc.)
{
"@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: