Building and Publishing a Dual-Package NPM Module
Min-jun Kim
Dev Intern · Leapcell

Introduction
In the ever-evolving JavaScript ecosystem, sharing reusable code is fundamental to efficient development. NPM packages serve as the cornerstone for this, allowing developers to modularize their solutions and contribute to the broader community. However, with the rise of ECMAScript Modules (ESM) alongside the long-standing CommonJS (CJS) standard, authoring a package that seamlessly integrates into both environments has become a crucial, yet sometimes challenging, task. Ignoring either standard can limit your package's adoption and force users into intricate workarounds. This article aims to demystify the process, providing a comprehensive guide on how to write, test, and publish your own NPM package, ensuring it gracefully supports both ESM and CJS.
Understanding the Core Concepts
Before diving into the implementation, let's establish a common understanding of the key concepts involved:
- CommonJS (CJS): This module system, popularized by Node.js, uses
require()to import modules andmodule.exportsto export them. It's synchronous and has been the default for server-side JavaScript for many years.// CJS import const myModule = require('./myModule.js'); // CJS export module.exports = { myFunction: () => console.log('Hello from CJS!') }; - ECMAScript Modules (ESM): The official module standard for JavaScript, introduced in ES2015. It uses
importandexportstatements, which are inherently asynchronous and designed for both browser and Node.js environments.// ESM import import { myFunction } from './myModule.js'; // ESM export export const myFunction = () => console.log('Hello from ESM!'); - Dual Package Hazard: This refers to the potential issues that arise when a package attempts to provide both CJS and ESM versions. Problems can occur if different environments resolve to different module types or if state is duplicated.
- Conditioned Exports: A powerful feature in
package.jsonthat allows you to define different entry points based on the environment (e.g.,importfor ESM,requirefor CJS,browserfor web). This is key to solving the dual package hazard.// package.json snippet for conditioned exports "exports": { ".": { "import": "./dist/esm/index.js", "require": "./dist/cjs/index.js" } } typefield inpackage.json: This field ("type": "module"or"type": "commonjs") defines the default module system for all.jsfiles within a package or scope. It plays a crucial role in how Node.js interprets files.- Transpilation: The process of converting source code written in one language or version into another. For JavaScript, this often means converting newer syntax (like ESM
import/export) to older syntax (like CJSrequire/module.exports) for broader compatibility, typically using tools like Babel or TypeScript.
Building a Dual-Package Module
Let's walk through the steps to create a simple utility package that greets the user, ensuring it works seamlessly with both CJS and ESM.
1. Project Setup and Initialization
First, create a new directory for your package and initialize an NPM project.
mkdir my-greeting-package cd my-greeting-package npm init -y
2. Source Code
Our package will have a single function that returns a greeting. We write this using modern ESM syntax.
src/index.js:
// src/index.js export function greet(name = 'World') { return `Hello, ${name}!`; }
3. Build Configuration and Transpilation
To support both CJS and ESM, we'll need to transpile our source code. We'll use Rollup.js for this, as it's highly configurable and excellent for library bundling.
npm install --save-dev rollup @rollup/plugin-terser @rollup/plugin-node-resolve
Create a rollup.config.js file:
// rollup.config.js import { terser } from '@rollup/plugin-terser'; import { nodeResolve } from '@rollup/plugin-node-resolve'; export default [ // CJS build { input: 'src/index.js', output: { file: 'dist/cjs/index.js', format: 'cjs', sourcemap: true, exports: 'named', // Ensures named exports are correctly handled for CJS }, plugins: [nodeResolve(), terser()], }, // ESM build { input: 'src/index.js', output: { file: 'dist/esm/index.js', format: 'esm', sourcemap: true, }, plugins: [nodeResolve(), terser()], }, ];
Add a build script to your package.json:
// package.json snippet "scripts": { "build": "rollup -c", "test": "node test/test.js" // We'll add this later }, "main": "dist/cjs/index.js", // Fallback for older Node.js or tools "module": "dist/esm/index.js", // Hint for bundlers like Webpack/Rollup "type": "commonjs", // Default type for the package
Run the build:
npm run build
This will create dist/cjs/index.js and dist/esm/index.js.
4. Configuring package.json for Dual-Package Support
This is the most critical step. We modify package.json to use conditioned exports, ensuring environments pick the correct module type.
// package.json { "name": "my-greeting-package", "version": "1.0.0", "description": "A simple package that greets the user.", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", "type": "commonjs", "exports": { ".": { "import": "./dist/esm/index.js", "require": "./dist/cjs/index.js", "default": "./dist/cjs/index.js" }, "./package.json": "./package.json" }, "files": [ "dist" ], "scripts": { "build": "rollup -c", "test": "node test/test.js" }, "keywords": [ "greeting", "esm", "cjs" ], "author": "Your Name", "license": "MIT", "devDependencies": { "@rollup/plugin-node-resolve": "^15.2.3", "@rollup/plugin-terser": "^0.4.4", "rollup": "^4.12.0" } }
main: Specifies the entry point for CJS environments (legacy fallback).module: Specifies the entry point for ESM-aware bundlers (e.g., Webpack, Rollup).type: "commonjs": This tells Node.js that.jsfiles in this package are CJS by default. If we had.mjsfiles for ESM, we wouldn't need this, or we could set it to"module"and use.cjsfor CJS files. With our current setup,dist/esm/index.jsstill functions as ESM because Rollup outputs ESM syntax and theexports.importcondition takes precedence.exports: This is where the magic happens..: Defines the package's primary entry point.import: Points to the ESM version whenimportis used.require: Points to the CJS version whenrequireis used.default: A fallback for environments that don't recognizeimportorrequireconditions, or for future-proofing."./package.json": "./package.json": Crucial for allowing other packages to access yourpackage.jsondirectly without issues.
files: Specifies the files to include when publishing to NPM. This keeps your package light.
5. Testing the Package
Robust testing is essential. We'll create two test files: one for ESM and one for CJS.
test/cjs-test.js:
// test/cjs-test.js const { greet } = require('../'); // Notice we require the package itself if (typeof greet === 'function') { console.log('CJS Test: greet is a function'); const result1 = greet(); console.log('CJS Test:', result1); // Expected: Hello, World! const result2 = greet('Alice'); console.log('CJS Test:', result2); // Expected: Hello, Alice! if (result1 === 'Hello, World!' && result2 === 'Hello, Alice!') { console.log('CJS Test PASSED'); } else { console.error('CJS Test FAILED'); process.exit(1); } } else { console.error('CJS Test FAILED: greet is not a function'); process.exit(1); }
test/esm-test.mjs:
// test/esm-test.mjs import { greet } from '../'; // Notice we import the package itself async function runEsmTest() { if (typeof greet === 'function') { console.log('ESM Test: greet is a function'); const result1 = greet(); console.log('ESM Test:', result1); // Expected: Hello, World! const result2 = greet('Bob'); console.log('ESM Test:', result2); // Expected: Hello, Bob! if (result1 === 'Hello, World!' && result2 === 'Hello, Bob!') { console.log('ESM Test PASSED'); } else { console.error('ESM Test FAILED'); process.exit(1); } } else { console.error('ESM Test FAILED: greet is not a function'); process.exit(1); } } runEsmTest().catch(err => { console.error('ESM Test encountered an error:', err); process.exit(1); });
Update your package.json scripts to run both tests:
// package.json snippet "scripts": { "build": "rollup -c", "test": "node test/cjs-test.js && node test/esm-test.mjs" },
Now, run your tests:
npm run test
You should see both tests pass, indicating that your package correctly exports for both CJS and ESM environments.
6. Publishing to NPM
Before publishing, ensure:
- You have an NPM account.
- You are logged in to NPM via your terminal (
npm login). - Your
package.jsonhas a uniquenameand appropriateversion. - The
filesarray correctly lists what should be published (e.g.,["dist", "README.md", "LICENSE"]).
Finally, to publish:
npm publish
If you ever need to update your package, increment the version in package.json and then run npm publish again.
Conclusion
Creating an NPM package that supports both ESM and CJS is a critical skill for modern JavaScript developers. By leveraging tools like Rollup for transpilation and meticulously configuring package.json with conditioned exports, you can deliver a robust, universally compatible module. This approach minimizes user friction and maximizes the reach of your valuable code. Embracing these practices ensures your package remains relevant and accessible across the diverse JavaScript landscape.

