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