DEV Community

Cover image for Building Universal npm Libraries: Supporting Both CJS and ESM
Evgenii Kalkutin
Evgenii Kalkutin

Posted on

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.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
Enter fullscreen mode Exit fullscreen mode

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"]
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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',
    }),
  ],
};
Enter fullscreen mode Exit fullscreen mode

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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
nevodavid profile image
Nevo David

Neat guide! How do you decide when it's finally okay to stop supporting the old system and just use the new one?

Collapse
 
ekalkutin profile image
Evgenii Kalkutin

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