Seamless CJS and ESM: Building Dual-Format Packages with Nx




🚀 Intro

The JavaScript module ecosystem is in transition. ESM (ECMAScript Modules) has become the modern standard, but CommonJS (CJS) remains deeply entrenched in the ecosystem.

ECMAScript Modules (ESM), introduced in ES6 (2015), is the standardised JavaScript module system designed to work seamlessly across both browsers and servers, unifying how JavaScript handles modularity.



Core advantages of ESM:

  • Enables better performance and optimisation through asynchronous loading and built-in tree-shaking via static analysis, allowing browsers and bundlers to eliminate unused code automatically.
  • 🌐 Provides universal compatibility with native browser support (no bundlers required) while also working seamlessly on the server-side, making it suitable for both environments.
  • Offers modern developer features, including top-level await support and dynamic imports in both module systems, giving developers more flexibility in how they structure and load their code.

As a library author, it’s important to support both legacy CommonJS projects and modern ESM projects. The solution is to publish a single npm package compatible with both module formats. This guide demonstrates how to build and publish a universal NPM package using the Nx Dev Toolkit and Rollup.



⚙️ Setting up Nx Workspace

Let’s start! Setting up an Nx workspace is the first step to building a supercharged, scalable project structure.

Create a publishable library (with rollup bundler)

bunx create-nx-workspace@22 dual-modules-workspace

cd dual-modules-workspace
bunx nx g @nx/js:lib packages/my-library --name my-library --importPath @dual-modules-workspace/my-library --bundler rollup --linter eslint --publishable --unitTestRunner vitest --setParserOptionsProject --useProjectJson
Enter fullscreen mode

Exit fullscreen mode

This creates:

...
packages
└── my-library
    ├── eslint.config.mjs
    ├── package.json
    ├── project.json
    ├── README.md
    ├── rollup.config.cjs
    ├── src/
    ├── tsconfig.json
    ├── tsconfig.lib.json
    ├── tsconfig.spec.json
    └── vite.config.ts
...
Enter fullscreen mode

Exit fullscreen mode



🔧 Setting up configurations

Now that the workspace is ready, it’s time to fine-tune the engine—let’s tweak the configs so everything runs smoothly and efficiently.

Rollup configuration

// packages/my-library/rollup.config.cjs

const { withNx } = require('@nx/rollup/with-nx');

module.exports = withNx(
  {
    main: './src/index.ts',
    outputPath: './dist',
    tsConfig: './tsconfig.lib.json',
    compiler: 'swc',
    format: ['esm', 'cjs'],
  },
  {
    output: {
      preserveModules: true, // Better tree-shaking
      preserveModulesRoot: '.', // Preserve module structure in output
    },
  }
);

Enter fullscreen mode

Exit fullscreen mode

Package configuration

// packages/my-library/package.json

{
  "name": "@dual-modules-workspace/my-library",
  "version": "0.0.1",
  "main": "./dist/index.cjs.js", // generated esm to cjs
  "module": "./dist/index.esm.js",
  "types": "./dist/index.d.ts", // generated index.esm.d.ts to index.d.ts
  "exports": {
    "./package.json": "./package.json",
    ".": {
      "types": "./dist/index.d.ts", // generated index.esm.d.ts to index.d.ts
      "require": "./dist/index.cjs.js", // Support for common js
      "import": "./dist/index.esm.js",
      "default": "./dist/index.esm.js" // Keep ESM as default
    }
  },
  "sideEffects": false // // Better tree-shaking
  ...
}

Enter fullscreen mode

Exit fullscreen mode

The exports field in package.json routes import to ESM and require to CJS automatically.

Typescript declarations

Optionally, we can disable source maps added to the package by setting the property sourceMap to false in the tsconfig.

// tsconfig.base.json (not tsconfig.json)

{
  "compilerOptions": {
  ...
  "sourceMap": false,
  ...
  }
}
Enter fullscreen mode

Exit fullscreen mode



📦 Build

It’s showtime! Let’s run nx build and watch out library come to life after all that setup.

bun nx build my-library
Enter fullscreen mode

Exit fullscreen mode

This outputs:

packages/my-library/dist/
├── index.cjs.js
├── index.d.ts
├── index.esm.js
└── src
    ├── index.d.ts
    └── lib
        ├── my-library.cjs.js
        ├── my-library.d.ts
        └── my-library.esm.js
Enter fullscreen mode

Exit fullscreen mode



🚢 Publishing

Nx comes with a built-in command to seamlessly publish our packages to the npm registry. It also natively supports semantic versioning with conventional commits, making release management smooth and predictable. To get started, follow the step-by-step guide here, and explore additional release configuration options here to tailor the setup to our project’s needs.



🎉 Outro

Building universal npm packages with Nx and Rollup becomes straightforward once you grasp the key concepts. By setting up dual-format outputs, organising our package.json exports correctly, and validating our builds with the right tools, we ensure our library runs smoothly across the entire JavaScript ecosystem.

With our library now ready to serve both legacy and modern JavaScript consumers, you’ve crafted a package that’s versatile, robust, and future-proof.

If you found this guide helpful, don’t forget to leave a reaction or comment on this post — it helps me understand what content resonates with you and motivates me to create more like this!



Source link

Leave a Reply

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