Master Dual ESM & CJS Modules with Exports Field in package.json
Master Dual ESM & CJS Modules with Exports Field in package.json

How to Use the Exports Field in package.json for Dual ESM and CJS npm Packages

Learn to use the exports field in package.json for seamless dual ESM and CJS module support in your JavaScript libraries.6 min


As JavaScript libraries gravitate towards ES modules (ESM), many developers still rely on CommonJS (CJS) importing patterns. Supporting both formats in your npm package doesn’t have to be complicated—the exports field in your package.json simplifies dual-module support, enabling users to easily work with your library, regardless of their node.js environment.

But what exactly is the exports field, and how do you use it effectively to support both ESM and CJS? Let’s explore this together using clear examples and practical tips.

Understanding the Exports Field

The “exports” field in your package.json allows you to define entry points for your package. It explicitly controls which files and paths can be imported by consumers of your library.

Previously, developers commonly used “main” and “module” in package.json to denote CommonJS and ESM entry points, respectively. However, the “exports” field offers greater flexibility and explicit control.

Using exports, you precisely define which file is exposed for each type of import, helping users of your package reliably resolve modules based on their environment. Node.js and bundlers like Webpack, Vite, or Rollup respect these exports and pick the correct files automatically.

Example Using Exports Field: ESM and CJS Dual Support

Let’s dive into a real-world example, documented in Vite’s library mode documentation. Consider a package.json configuration like this:

{
  "name": "my-lib",
  "type": "module",
  "exports": {
    ".": {
      "import": "./dist/my-lib.es.js",
      "require": "./dist/my-lib.cjs.js"
    }
  }
}

Let’s break down this simple structure step-by-step:

  • name: Your package name as it appears on npm.
  • type: Declared as “module” means Node.js treats .js files as ES modules by default.
  • exports: Explicitly describes what file is served based on the module system used during import:
    • “import”: Defines the path for ES module import statements (using import).
    • “require”: Defines the path used by CommonJS require statements (using require).

Whenever a consumer imports your library like:

import myLib from 'my-lib';

They’ll automatically get the ESM version (./dist/my-lib.es.js). On the other hand, if someone uses:

const myLib = require('my-lib');

Then your module exports the CommonJS version (./dist/my-lib.cjs.js).

How ‘import’ and ‘require’ Work in Exports

Within the exports field, the terms “import” and “require” act as selectors, automatically matching the user’s chosen module syntax.

Node.js recognizes the syntax consumers are using and picks the corresponding file path. This toggle behavior means you no longer need to worry about environment checks or manual entries for different environments.

Simply defining these fields correctly lets your package seamlessly support dual module resolution, increasing usability and consumer satisfaction, especially when your library caters to both newer and older ecosystems.

Practical Application: ESM and CJS Compatibility

Imagine you’ve built a useful JavaScript utility library and published it on npm. Users might prefer different import styles depending on their setup. With exports, here’s how usage looks practically:

Using ES Module syntax:

// ESM import
import { usefulFunction } from 'my-lib';

usefulFunction();

For CommonJS syntax:

// CommonJS require
const { usefulFunction } = require('my-lib');

usefulFunction();

In either scenario, your configuration from above seamlessly responds by providing the correct version.

If you’d like consumers to have deeper imports, like specific submodules or utilities, exports supports detailed branching paths too. Simply adding another level to the exports field lets you map specific files according to syntax and import path.

Do ‘main’ and ‘module’ Still Matter?

The old ways we’ve been using—specifically “main” (for CommonJS) and “module” (for ES modules)—have been straightforward but less flexible.

The introduction of “exports” provides a modern, more explicit method of export definitions. With clear exports defined, “main” and “module” become somewhat redundant but often still exist for backward compatibility reasons.

Nevertheless, the exports field has priority and overrides “main” and “module”. Once you define exports, newer Node.js and bundlers prioritize exports over these traditional fields.

Comparison of Fields:

Field Module type Usefulness today
“main” CommonJS (old) Backward compatibility
“module” ESM (introduced by bundlers) Mostly legacy bundler support, less important today
“exports” Both CJS and ESM (modern) Recommended best practice. Explicit, flexible.

Best Practices for Dual ESM and CJS Support

To effectively support dual module resolutions, follow these reliable tips:

  1. Clearly define exports for each module format. Explicitly declare “import” and “require” paths in exports.
  2. Include clear file extensions. Node.js prefers explicit extensions for accurate resolution.
  3. Align your directory structure logically. Group ESM and CJS builds neatly for maintainability.
  4. Test both module paths during CI/CD. Catch errors early by automating tests.

Avoid these common pitfalls:

  • Missing or mismatched file extensions: Can cause resolution errors in certain Node.js environments.
  • Relying solely on “main” and “module” fields: These may become less supported as exports gains popularity.
  • Inconsistent build outputs: Ensure both ESM and CJS outputs function equally well. Test thoroughly.

Optimize Your package.json Configuration

To wrap things up neatly, it’s good practice to supplement your exports with clear documentation, instructing users how to import your library. Additionally, employ tools like Rollup or Vite to easily manage ESM and CJS output generation with minimal overhead.

By optimizing this setup, you’ll save users from hassles, confusion, and troubleshooting, vastly elevating developer experience around your package.

We’ve covered the essentials of leveraging your package.json’s exports field to support both legacy and modern module systems comprehensively.

Whether you’re just starting or maintaining a popular package, the explicit and flexible dual-module support provided by the exports field significantly enhances your library’s usability across multiple JavaScript ecosystems (more JavaScript articles here).

How are you planning to upgrade your package structure to support dual ESM and CJS modules? Share your strategies, or ask a question below to start the conversation!


Like it? Share with your friends!

Shivateja Keerthi
Hey there! I'm Shivateja Keerthi, a full-stack developer who loves diving deep into code, fixing tricky bugs, and figuring out why things break. I mainly work with JavaScript and Python, and I enjoy sharing everything I learn - especially about debugging, troubleshooting errors, and making development smoother. If you've ever struggled with weird bugs or just want to get better at coding, you're in the right place. Through my blog, I share tips, solutions, and insights to help you code smarter and debug faster. Let’s make coding less frustrating and more fun! My LinkedIn Follow Me on X

0 Comments

Your email address will not be published. Required fields are marked *