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.exports
to 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
import
andexport
statements, 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.json
that allows you to define different entry points based on the environment (e.g.,import
for ESM,require
for CJS,browser
for 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" } }
type
field inpackage.json
: This field ("type": "module"
or"type": "commonjs"
) defines the default module system for all.js
files 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.js
files in this package are CJS by default. If we had.mjs
files for ESM, we wouldn't need this, or we could set it to"module"
and use.cjs
for CJS files. With our current setup,dist/esm/index.js
still functions as ESM because Rollup outputs ESM syntax and theexports.import
condition takes precedence.exports
: This is where the magic happens..
: Defines the package's primary entry point.import
: Points to the ESM version whenimport
is used.require
: Points to the CJS version whenrequire
is used.default
: A fallback for environments that don't recognizeimport
orrequire
conditions, or for future-proofing."./package.json": "./package.json"
: Crucial for allowing other packages to access yourpackage.json
directly 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.json
has a uniquename
and appropriateversion
. - The
files
array 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.