🚀 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
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
...
🔧 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
},
}
);
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
...
}
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,
...
}
}
📦 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
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
🚢 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!
