Publishing packages that work with both CommonJS (CJS) and ECMAScript Modules (ESM) is surprisingly straightforward once you understand how Node.js resolves module types. The core challenge isn’t creating the two formats, but signaling to Node.js which one to use based on the consumer’s environment.

Let’s see this in action. Imagine a simple package, my-dual-package, with a single export:

// src/index.js
export const greet = (name) => `Hello, ${name}!`;

We want this to be usable in both require('./my-dual-package') and import { greet } from './my-dual-package'.

Here’s the setup:

1. Build Process:

We need to compile our source files (written in modern JS, perhaps with ES Modules syntax) into both CJS and ESM formats. Tools like esbuild, rollup, or swc are excellent for this.

Using esbuild for a quick example:

  • CJS Build:

    esbuild src/index.js --bundle --outfile=dist/cjs/index.js --format=cjs --platform=node
    

    This creates dist/cjs/index.js with CommonJS require and module.exports.

  • ESM Build:

    esbuild src/index.js --bundle --outfile=dist/esm/index.js --format=esm --platform=node
    

    This creates dist/esm/index.js with ES Module import and export.

2. package.json Configuration:

This is where the magic happens. We tell Node.js where to find our dual formats.

{
  "name": "my-dual-package",
  "version": "1.0.0",
  "main": "dist/cjs/index.js",
  "module": "dist/esm/index.js",
  "type": "module", // Crucial for ESM-first resolution
  "exports": {
    ".": {
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.js",
      "types": "./dist/types/index.d.ts" // For TypeScript users
    }
  },
  "files": [
    "dist"
  ],
  "scripts": {
    "build": "npm run build:cjs && npm run build:esm",
    "build:cjs": "esbuild src/index.js --bundle --outfile=dist/cjs/index.js --format=cjs --platform=node",
    "build:esm": "esbuild src/index.js --bundle --outfile=dist/esm/index.js --format=esm --platform=node",
    "prepublishOnly": "npm run build"
  }
}
  • "main": Points to the CJS entry point. This is the fallback for older Node.js versions or bundlers that don’t understand "exports".
  • "module": Points to the ESM entry point. Some bundlers (like older Webpack or Rollup) use this directive to prefer ESM.
  • "type": "module": This is key. It tells Node.js that .js files in this package should be treated as ES Modules by default. This is important for making ESM the primary path.
  • "exports": This is the modern, most powerful way to define entry points.
    • ".": Refers to the package root (e.g., import 'my-dual-package').
    • "import": Specifies the ESM entry point. Node.js will use this when the consumer is in an ESM context ("type": "module" in their package.json or using .mjs files).
    • "require": Specifies the CJS entry point. Node.js will use this when the consumer is in a CJS context ("type": "commonjs" or no "type" field in their package.json, or using .cjs files).
    • "types": If you’re using TypeScript, this points to your generated declaration files.
  • "files": Ensures that only the dist directory (containing your compiled code) is included when the package is published.
  • "prepublishOnly": A hook to automatically build your package before it’s published to npm, ensuring you don’t publish uncompiled source code.

How Node.js Resolves:

When a consumer imports or requires your package:

  1. Node.js checks the consumer’s package.json for "type":

    • If the consumer has "type": "module", Node.js will look for an ESM entry point. It first consults the "exports" map. If "exports" defines an "import" condition, it uses that. Otherwise, it falls back to "module".
    • If the consumer has "type": "commonjs" or no "type" field, Node.js will look for a CJS entry point. It consults "exports" for a "require" condition. If not found, it falls back to "main".
  2. If "exports" is present: Node.js uses the conditions defined in "exports" based on the consumer’s environment and module resolution flags (like --experimental-modules). The "import" condition is checked for ESM imports, and the "require" condition is checked for CJS require calls.

  3. If "exports" is not present: Node.js falls back to "main" for CJS and "module" for ESM (though "module" is a convention, not a Node.js directive itself; bundlers often use it).

The "exports" field is superior because it allows for explicit mapping of conditions, ensuring the correct file is loaded regardless of whether the consumer is using CJS or ESM.

One common pitfall when migrating an existing CJS package is forgetting to set "type": "module" in your own package.json. Without it, Node.js might incorrectly assume your .js files are CJS even when you intend them to be ESM, leading to errors when consumers try to import them.

The next step is often handling subpath exports, allowing consumers to import specific files within your package, like import { helper } from 'my-dual-package/helper'.

Want structured learning?

Take the full Npm course →