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 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:

Apr 15, 2025 - 22:30
 0
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 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: