πŸš€ Building a Modern npm Package with Rollup: A Deep Dive into CJS & ESM Configurations

Debraj KarmakarDebraj Karmakar
14 min read

Creating a versatile npm package today means supporting multiple module systems. In this post, we explore why you should build your package with both CommonJS (CJS) and ECMAScript Module (ESM) formats, how to configure Rollup for this purpose, and the best practices for an optimized build process. We’ve enriched our code examples with detailed inline comments to guide you through the process. πŸ’‘


πŸ” What Are CJS & ESM?

CommonJS (CJS)

  • Definition:
    The traditional module system for Node.js.

  • Usage:
    Often found in legacy projects or environments that haven’t yet transitioned to modern JavaScript tooling.

ECMAScript Modules (ESM)

  • Definition:
    The standardized module system supported by modern browsers and recent Node.js versions.

  • Usage:
    Enables features like tree shaking - removing unused code to yield smaller and faster bundles.


πŸ€” Why Provide Both Builds?

Offering both builds grants several advantages:

Compatibility

  • Why:
    Different environments (legacy Node.js vs. modern browsers) require distinct module formats.

  • When:
    When you want your library to be used by a broader audience.

    Tip: Supporting both formats helps you reach users on both ends of the spectrum! πŸ‘

Tree Shaking

  • Why:
    ESM supports advanced optimizations that remove unused code, resulting in leaner bundles.

  • When:
    Particularly useful for front-end libraries where bundle size matters.

    Highlight: Smaller bundles = faster load times! ⚑

Interoperability

  • Why:
    Some tools and libraries only support one module format.

  • When:
    When integrating with diverse ecosystems, offering both builds ensures smooth integration.

Future-Proofing

  • Why:
    As the ecosystem evolves, ESM is becoming the standard.

  • When:
    Always include an ESM build to stay ahead in modern development.

    Note: Be prepared for the future! πŸš€


πŸ“‹ Prerequisites

Before diving into the Rollup configuration, ensure you have the following installed and set up:

  • Node.js: A recent version installed on your system.

  • pnpm: For fast, disk space-efficient package management. Learn more about pnpm

  • TypeScript (Optional): If your project uses TypeScript, ensure it’s configured properly.

  • Rollup: Your chosen module bundler to compile and bundle your code.

  • Modern JavaScript Knowledge: Familiarity with modules, package configuration, and modern development practices.

    πŸ’‘ Pro Tip: Using pnpm can save you both time and disk space! πŸŽ‰


βš™οΈ Rollup and Its Advantages

Rollup is popular among library authors for its:

  • Simplicity:
    Clean, easy-to-understand configuration files.

  • Tree Shaking Capabilities:
    Automatically removes unused code to reduce bundle sizes.

  • Extensive Plugin Ecosystem:
    Supports TypeScript, CSS processing, and more.

  • Flexibility:
    Allows different build targets (CJS, ESM, UMD, IIFE) with minimal configuration tweaks.


πŸ’» Detailed Rollup Configurations

Below are several Rollup configuration scenarios with in-depth code commentary. Notice that each configuration includes detailed instructions for every property-except for the import statements, which remain untouched.


1️⃣ Only CJS Build

// rollup.config.js (CJS Only)
import resolve from "@rollup/plugin-node-resolve";  // πŸ‘‰ Locates and bundles modules from `node_modules`.
import commonjs from "@rollup/plugin-commonjs";  // πŸ‘‰ Converts CommonJS modules to ES6.
import typescript from "@rollup/plugin-typescript";  // πŸ‘‰ Transpiles TypeScript files to JavaScript.
import dts from "rollup-plugin-dts";  // πŸ‘‰ Generates TypeScript declaration (.d.ts) files.
import { terser } from "@rollup/plugin-terser";  // πŸ‘‰ Minifies the final bundle.
import peerDepsExternal from "rollup-plugin-peer-deps-external";  // πŸ‘‰ Automatically marks peer dependencies as external.
import postcss from "rollup-plugin-postcss";  // πŸ‘‰ Processes CSS files.
import packageJson from "./package.json";  // πŸ‘‰ Import package metadata to dynamically set output paths (e.g., "main", "module", "types") in your config.

export default [
  // πŸ“Œ CJS build configuration
  {
    // Input: The starting point for bundling your library.
    input: "packages/index.ts", // This is the main entry file.

    // Output: Defines where and how the bundled code is generated.
    output: {
      file: packageJson.main, // Path taken from package.json (e.g., dist/index.js)
      format: "cjs",          // Specifies the CommonJS module format.
      sourcemap: true,        // Generates a source map to aid in debugging.
    },

    // External: Tells Rollup which modules to exclude from the bundle.
    external: Object.keys(packageJson.peerDependencies || {}),

    // Plugins: A series of steps that transform and optimize your code.
    plugins: [
      typescript({
        tsconfig: "./tsconfig.json", // Uses your TypeScript configuration.
        outDir: "dist",              // Output directory for transpiled files.
        declarationDir: "dist",      // Directory for TypeScript declaration files.
        exclude: [
          "packages/tests/**/*",     // Exclude test files from the build.
          "packages/stories/**/*",   // Exclude storybook files from the build.
        ],
      }),
      peerDepsExternal(),            // Automatically externalizes peer dependencies.
      resolve({
        extensions: [".js", ".jsx", ".ts", ".tsx"], // Resolves these file extensions.
        skip: Object.keys(packageJson.peerDependencies || {}), // Skips bundling peer deps.
      }),
      commonjs(),                    // Converts CommonJS modules to ES modules.
      terser(),                      // Minifies the output bundle for smaller size.
      postcss({
        extensions: [".css"],        // Processes CSS files with these extensions.
        inject: true,                // Injects CSS into the JS bundle.
        extract: false,              // Does not extract CSS into a separate file.
        minimize: true,              // Minimizes the CSS output.
      }),
    ],
  },

  // πŸ“š Declaration files build for TypeScript consumers
  {
    // Input: The file that holds TypeScript type declarations.
    input: packageJson.types,

    // Output: Generates a declaration file in ESM format.
    output: [{ file: packageJson.types, format: "esm" }],

    // Plugins: Generates .d.ts files.
    plugins: [dts.default()],

    // External: Excludes CSS files from declaration bundling.
    external: [/\.css$/],
  },

  // 🎨 CSS build configuration
  {
    // Input: The main CSS file for your package.
    input: "packages/index.css",

    // Output: Outputs the processed CSS as an ESM module.
    output: [{ file: "dist/index.css", format: "esm" }],

    // Plugins: Processes and minimizes CSS.
    plugins: [
      postcss({
        extract: true,  // Extracts CSS into a separate file.
        minimize: true, // Minimizes the CSS for optimized performance.
      }),
    ],
  },
];

Detailed Instructions Recap:

  • input: Defines where Rollup begins processing your code.

  • output: Configures file location, module format, and debugging support via sourcemaps.

  • external: Prevents bundling of dependencies that should be installed by consumers.

  • plugins: Runs various transformations (transpilation, minification, CSS processing) to optimize your bundle.


2️⃣ Only ESM Build

// rollup.config.js (ESM Only)
import resolve from "@rollup/plugin-node-resolve";  // πŸ‘‰ Locates and bundles modules from `node_modules`.
import commonjs from "@rollup/plugin-commonjs";  // πŸ‘‰ Converts CommonJS modules to ES6.
import typescript from "@rollup/plugin-typescript";  // πŸ‘‰ Transpiles TypeScript files to JavaScript.
import dts from "rollup-plugin-dts";  // πŸ‘‰ Generates TypeScript declaration (.d.ts) files.
import { terser } from "@rollup/plugin-terser";  // πŸ‘‰ Minifies the final bundle.
import peerDepsExternal from "rollup-plugin-peer-deps-external";  // πŸ‘‰ Automatically marks peer dependencies as external.
import postcss from "rollup-plugin-postcss";  // πŸ‘‰ Processes CSS files.
import packageJson from "./package.json";  // πŸ‘‰ Import package metadata to dynamically set output paths (e.g., "main", "module", "types") in your config.

export default [
  // πŸ“Œ ESM build configuration
  {
    // Input: Starting file for bundling.
    input: "packages/index.ts", // Main entry point of your library.

    // Output: Configures the bundle for ESM usage.
    output: {
      file: packageJson.main, // This field typically points to the ESM build in package.json.
      format: "esm",          // Sets the module format to ECMAScript Module.
      sourcemap: true,        // Enables source maps for debugging.
    },

    // External: Prevents bundling of modules listed as peer dependencies.
    external: Object.keys(packageJson.peerDependencies || {}),

    // Plugins: Transform the code with a series of build steps.
    plugins: [
      typescript({
        tsconfig: "./tsconfig.json", // Uses your TypeScript configuration.
        outDir: "dist",              // Sets the output directory.
        declarationDir: "dist",      // Generates declaration files in the specified directory.
        exclude: [
          "packages/tests/**/*",     // Excludes test files.
          "packages/stories/**/*",   // Excludes story files.
        ],
      }),
      peerDepsExternal(),            // Externalizes peer dependencies.
      resolve({
        extensions: [".js", ".jsx", ".ts", ".tsx"], // Resolves module file types.
        skip: Object.keys(packageJson.peerDependencies || {}), // Avoids bundling peer deps.
      }),
      commonjs(),                    // Converts CommonJS modules to ESM.
      terser(),                      // Minifies the bundle to reduce file size.
      postcss({
        extensions: [".css"],        // Processes CSS files.
        inject: true,                // Injects CSS into the JS bundle.
        extract: false,              // Keeps CSS within the bundle instead of extracting it.
        minimize: true,              // Minimizes the CSS.
      }),
    ],
  },

  // πŸ“š Declaration files build for TypeScript consumers
  {
    // Input: Type declaration file.
    input: packageJson.types,

    // Output: Generates the declaration file in ESM format.
    output: [{ file: packageJson.types, format: "esm" }],

    // Plugins: Bundles the declaration files.
    plugins: [dts.default()],

    // External: Ensures CSS files are not processed.
    external: [/\.css$/],
  },

  // 🎨 CSS build configuration
  {
    // Input: CSS entry file.
    input: "packages/index.css",

    // Output: Processes and outputs CSS as an ESM module.
    output: [{ file: "dist/index.css", format: "esm" }],

    // Plugins: Handles CSS extraction and minification.
    plugins: [
      postcss({
        extract: true,  // Extracts CSS to a separate file for browser usage.
        minimize: true, // Minimizes the CSS output.
      }),
    ],
  },
];

Detailed Instructions Recap:

  • input: Identifies your library’s main file.

  • output: Specifies the target file, module format (ESM), and debugging via sourcemaps.

  • external: Excludes peer dependencies from the bundle.

  • plugins: Transforms your TypeScript code, resolves modules, converts CommonJS modules, minifies, and processes CSS.


3️⃣ Both CJS & ESM Builds

// rollup.config.js (CJS + ESM)
import resolve from "@rollup/plugin-node-resolve";  // πŸ‘‰ Locates and bundles modules from `node_modules`.
import commonjs from "@rollup/plugin-commonjs";  // πŸ‘‰ Converts CommonJS modules to ES6.
import typescript from "@rollup/plugin-typescript";  // πŸ‘‰ Transpiles TypeScript files to JavaScript.
import dts from "rollup-plugin-dts";  // πŸ‘‰ Generates TypeScript declaration (.d.ts) files.
import { terser } from "@rollup/plugin-terser";  // πŸ‘‰ Minifies the final bundle.
import peerDepsExternal from "rollup-plugin-peer-deps-external";  // πŸ‘‰ Automatically marks peer dependencies as external.
import postcss from "rollup-plugin-postcss";  // πŸ‘‰ Processes CSS files.
import packageJson from "./package.json";  // πŸ‘‰ Import package metadata to dynamically set output paths (e.g., "main", "module", "types") in your config.

const commonExclude = [
  "packages/tests/**/*",     // Excludes test files.
  "packages/stories/**/*",   // Excludes story files.
];

const commonPlugins = [
  peerDepsExternal(), // Automatically externalizes peer dependencies.
  resolve({
    extensions: [".js", ".jsx", ".ts", ".tsx"], // Supports these file extensions.
    skip: Object.keys(packageJson.peerDependencies || {}), // Skips bundling of peer deps.
  }),
  commonjs(), // Converts CommonJS modules to ESM.
  terser(),   // Minifies the code.
  postcss({
    extensions: [".css"], // Processes CSS files.
    inject: true,         // Injects CSS into the JavaScript bundle.
    extract: false,       // Keeps CSS in the JS bundle.
    minimize: true,       // Minimizes CSS output.
  }),
];

const external = Object.keys(packageJson.peerDependencies || {});

function createTsPlugin(outDir) {
  // Returns a TypeScript plugin instance configured for a specific output directory.
  return typescript({
    tsconfig: "./tsconfig.json",
    outDir,               // Output directory changes based on the build (CJS or ESM).
    declarationDir: outDir, // Declaration files are generated in the same folder.
    exclude: commonExclude, // Excludes tests and stories from the build.
  });
}

export default [
  // πŸ“Œ CommonJS build configuration
  {
    // Input: The entry point for your library.
    input: "packages/index.ts",

    // Output: Defines where the CJS build will be output.
    output: {
      file: packageJson.main, // For example, "dist/cjs/index.js".
      format: "cjs",          // Uses CommonJS module format.
      sourcemap: false,       // Source maps disabled to reduce file size (can be enabled if needed).
    },

    // External: Prevents bundling of peer dependencies.
    external,

    // Plugins: Uses a custom TypeScript plugin targeting the CJS directory along with shared plugins.
    plugins: [
      createTsPlugin("dist/cjs"),
      ...commonPlugins,
    ],
  },

  // πŸ“Œ ESM build configuration
  {
    // Input: Entry file for the library.
    input: "packages/index.ts",

    // Output: Defines where the ESM build will be written.
    output: {
      file: packageJson.module, // For example, "dist/esm/index.js".
      format: "esm",            // Sets module format to ESM.
      sourcemap: false,         // Source maps are disabled (adjust as necessary).
    },

    // External: Excludes peer dependencies.
    external,

    // Plugins: Uses a custom TypeScript plugin for the ESM directory plus shared plugins.
    plugins: [
      createTsPlugin("dist/esm"),
      ...commonPlugins,
    ],
  },

  // πŸ“š Declaration files build
  {
    // Input: Points to the generated TypeScript declaration file.
    input: "dist/esm/index.d.ts",

    // Output: Bundles declaration files into a single output.
    output: [{ file: packageJson.types, format: "esm" }],

    // Plugins: Uses a plugin dedicated to handling .d.ts files.
    plugins: [dts.default()],

    // External: Prevents processing of CSS files.
    external: [/\.css$/],
  },

  // 🎨 CSS build configuration
  {
    // Input: Main CSS file.
    input: "packages/index.css",

    // Output: Specifies the destination for the processed CSS.
    output: [{ file: "dist/index.css", format: "esm" }],

    // Plugins: Processes CSS and extracts it into its own file.
    plugins: [
      postcss({
        extract: true,  // Extracts CSS to a separate file (ideal for browser use).
        minimize: true, // Minimizes the CSS for performance.
      }),
    ],
  },
];

Detailed Instructions Recap:

  • input: Same entry file is used for both builds.

  • output: Separate output files are defined for CJS and ESM builds (using file for single-file outputs).

  • external: Ensures that peer dependencies aren’t bundled with your library.

  • plugins: Uses a helper function to customize the TypeScript plugin for different output directories while sharing common plugins.


4️⃣ Optimized CJS & ESM Builds with Code Splitting

// rollup.config.js (Optimized CJS + ESM)
import resolve from "@rollup/plugin-node-resolve";  // πŸ‘‰ Locates and bundles modules from `node_modules`.
import commonjs from "@rollup/plugin-commonjs";  // πŸ‘‰ Converts CommonJS modules to ES6.
import typescript from "@rollup/plugin-typescript";  // πŸ‘‰ Transpiles TypeScript files to JavaScript.
import dts from "rollup-plugin-dts";  // πŸ‘‰ Generates TypeScript declaration (.d.ts) files.
import { terser } from "@rollup/plugin-terser";  // πŸ‘‰ Minifies the final bundle.
import peerDepsExternal from "rollup-plugin-peer-deps-external";  // πŸ‘‰ Automatically marks peer dependencies as external.
import postcss from "rollup-plugin-postcss";  // πŸ‘‰ Processes CSS files.
import packageJson from "./package.json";  // πŸ‘‰ Import package metadata to dynamically set output paths (e.g., "main", "module", "types") in your config.

const commonExclude = [
  "packages/tests/**/*",     // Excludes test files.
  "packages/stories/**/*",   // Excludes story files.
];

const commonPlugins = [
  peerDepsExternal(), // Externalizes peer dependencies.
  resolve({
    extensions: [".js", ".jsx", ".ts", ".tsx"], // Handles multiple file extensions.
    skip: Object.keys(packageJson.peerDependencies || {}), // Skips bundling for peer deps.
  }),
  commonjs(), // Converts CommonJS modules to ES modules.
  terser(),   // Minifies the bundle.
  postcss({
    extensions: [".css"], // Processes CSS files.
    inject: true,         // Injects CSS into the bundle.
    extract: false,       // Does not extract CSS separately.
    minimize: true,       // Minimizes CSS.
  }),
];

const external = Object.keys(packageJson.peerDependencies || {});

function createTsPlugin(outDir) {
  // Configures TypeScript compilation for a given output directory.
  return typescript({
    tsconfig: "./tsconfig.json",
    outDir,               // Directs output to the specified directory (CJS or ESM).
    declarationDir: outDir, // Outputs declaration files alongside transpiled code.
    exclude: commonExclude, // Excludes test and story files.
  });
}

export default [
  // πŸ“Œ Optimized CommonJS build with manual chunking
  {
    // Input: Entry file for the build.
    input: "packages/index.ts",

    // Output: Uses a directory-based output to enable code splitting.
    output: {
      dir: packageJson.main, // For example, "dist/cjs" directory.
      format: "cjs",         // Sets module format to CommonJS.
      sourcemap: true,       // Enables source maps for debugging.
      // Manual chunking: Separates vendor code for better caching.
      manualChunks(id) {
        if (id.includes("node_modules")) return "vendor";
      }
    },

    // External: Excludes peer dependencies.
    external,

    // Plugins: Combines a custom TypeScript plugin with shared plugins.
    plugins: [
      createTsPlugin("dist/cjs"),
      ...commonPlugins,
    ],
  },

  // πŸ“Œ Optimized ESM build with manual chunking
  {
    // Input: Same entry file as the CJS build.
    input: "packages/index.ts",

    // Output: Directory-based output for ESM with code splitting.
    output: {
      dir: packageJson.module, // For example, "dist/esm" directory.
      format: "esm",           // Uses ECMAScript Module format.
      sourcemap: true,         // Enables source maps for debugging.
      // Manual chunking: Separates vendor modules.
      manualChunks(id) {
        if (id.includes("node_modules")) return "vendor";
      }
    },

    // External: Prevents bundling of peer dependencies.
    external,

    // Plugins: Uses a custom TypeScript plugin for ESM plus shared plugins.
    plugins: [
      createTsPlugin("dist/esm"),
      ...commonPlugins,
    ],
  },

  // πŸ“š Declaration files build
  {
    // Input: Points to the main declaration file generated in the ESM build.
    input: "dist/esm/index.d.ts",

    // Output: Bundles declaration files into one file for consumers.
    output: [{ file: packageJson.types, format: "esm" }],

    // Plugins: Uses the dts plugin to process .d.ts files.
    plugins: [dts.default()],

    // External: Excludes CSS files from the declaration bundle.
    external: [/\.css$/],
  },

  // 🎨 CSS build configuration
  {
    // Input: The main CSS file.
    input: "packages/index.css",

    // Output: Processes CSS and outputs it as a separate file.
    output: [{ file: "dist/index.css", format: "esm" }],

    // Plugins: Extracts and minimizes CSS.
    plugins: [
      postcss({
        extract: true,  // Extracts CSS into its own file.
        minimize: true, // Minimizes CSS for performance.
      }),
    ],
  },
];

Detailed Instructions Recap:

  • input: Uses the same entry point for both builds.

  • output: Uses directory-based outputs (dir) to allow Rollup to perform code splitting, isolating vendor code via manual chunks.

  • external: Excludes modules that should not be bundled.

  • plugins: Combines a custom TypeScript configuration (via a helper function) with shared plugins to optimize both builds.


5️⃣ Proper Configuration for Different Package Types

UMD/IIFE for Browser Consumption

// rollup.config.js (UMD/IIFE Build)
export default {
  // Input: The starting point for bundling your library.
  input: "packages/index.ts",

  // Output: Configures the bundle for direct browser usage.
  output: {
    file: "dist/umd/my-package.min.js", // Output file for browser consumption.
    format: "umd",                     // UMD format ensures compatibility in various environments.
    name: "MyPackage",                 // Global variable name exposed in browsers.
    sourcemap: true,                   // Generates source maps to help with debugging.
  },

  // Plugins: Transforms and optimizes your code for the browser.
  plugins: [
    typescript({ tsconfig: "./tsconfig.json" }), // Transpiles TypeScript code.
    resolve({ extensions: [".js", ".jsx", ".ts", ".tsx"] }), // Resolves modules.
    commonjs(), // Converts CommonJS modules to ESM for compatibility.
    terser(),   // Minifies the bundle to reduce file size.
  ],

  // External: Prevents bundling of peer dependencies (e.g., react, react-dom).
  external: Object.keys(packageJson.peerDependencies || {}),
};

Detailed Instructions Recap:

  • input: Sets the entry file for the UMD build.

  • output: Specifies the output file, module format (UMD), global name, and enables sourcemaps for debugging.

  • external: Ensures that external dependencies are not bundled into the final output.

  • plugins: Runs necessary transformations and optimizations tailored for browser consumption.

Hybrid Packages with Conditional Exports

To allow consumers using either import or require, you can configure your package.json like this:

{
  "name": "my-package",
  "version": "1.0.0",
  "type": "module",
  "main": "dist/cjs/index.js",
  "module": "dist/esm/index.js",
  "exports": {
    "import": "./dist/esm/index.js",
    "require": "./dist/cjs/index.js"
  },
  "files": ["dist"],
  "peerDependencies": {
    "react": ">=16.8.0",
    "react-dom": ">=16.8.0"
  }
}

Detailed Instructions Recap:

  • main/module: Specifies separate entry points for CommonJS and ESM consumers.

  • exports: Configures conditional exports to support both import and require seamlessly.


πŸ“Œ Best Practices Recap

  • Exclude tests and stories: Keep your production bundle lean.

  • Externalize peer dependencies: Prevent bundling libraries that consumers should install separately.

  • Use the right output format: Configure UMD/IIFE for browser use, CJS for legacy environments, and ESM for modern bundlers.

  • Conditional exports: Leverage the exports field in package.json to support both import and require without breaking compatibility.

  • Documentation: Include clear inline comments and README instructions to assist consumers.

Pro Tip: Always test your package locally using tools like pnpm pack or pnpm link before publishing! πŸ”§


πŸš€ Conclusion

Building an npm package with both CJS and ESM builds using Rollup not only ensures broad compatibility but also positions your library for future growth. With this guide - including configurations for different package types (UMD/IIFE, hybrid packages with conditional exports, etc.) - you now have a robust roadmap to deliver a high-quality, optimized, and versatile npm package.

Happy coding - and happy bundling! πŸŽ‰

5
Subscribe to my newsletter

Read articles from Debraj Karmakar directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Debraj Karmakar
Debraj Karmakar