Shipping ESM & CJS in one Package

Historically, Node.js and NPM packages relied on CommonJS (CJS) due to the absence of a standard module system of JavaScript. However, when ECMAScript modules (ESM) finally show up as the standard solution, everyone started migrating to native ESM gradually.

// CJS
const Button = require('./Button.ts');
// ESM
import { Button } from './Button.ts';

Experimental support of ESM has been introduced in Node.js v12, and stabilized v14.17.0. However, the migration of the JavaScript ecosystem to ESM is still ongoing. As of 2025, some libraries are now only available in ESM format but the majority of libraries still provide dual formats, CJS & ESM.

State at Spendesk

Our Design system, Grapes, is built with the latest technologies: latest version of Vite, latest version of Vitest and obviously, we are using ESM.

On the opposite side, the frontend at Spendesk has a lot of legacy part. Deprecated libraries and shiny, brand new libraries coexiste together in a large single-page application. The tech stack is also showing its age, relying on Webpack 4 and Jest, with the latter being incompatible (or with great difficulty - see here) with ESM.

This eclectic eco-system forces our Design System, Grapes, to be shipped with both CJS & ESM formats.

Interestingly, the CJS format is only used for Jest tests on the frontend repository, while the ESM format is used in production. This discrepancy means that the code tested in unit tests is not the same as the code shipped to production. Funny isn’t it?

Welcome to the burden of shipping ESM & CJS in one Package!

Bundling

So we have two copies of our design system code with slightly different module syntax to maintain, not an ideal solution.

Fortunately, Vite provides a very easy way to generate a library with both ESM and CJS formats. To achieve this, we only need to add the formats attribute to our vite.config.js file. Here’s an snippet from our configuration file:

export default defineConfig({
  plugins: [react()],
  build: {
    target: 'esnext',
    emptyOutDir: false,
    sourcemap: false,
    lib: {
      entry: 'src/index.ts',
      fileName: 'index',
      cssFileName: 'style',
      formats: ['es', 'cjs'], // Define both ESM & CJS formats
    },
  },
});

Obvisouly, your build time will be roughly doubled. This is because Vite will generate two separate builds: one for ESM and another for CJS.

Exports in package.json

Node allows multiple formats to coexist in a single package by providing multiple attributes to your package.json. The relevant part of our package.json file is configured as follows:

{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs", // ESM
      "require": "./dist/index.js" // CJS
    },
    "./style": "./dist/style.css"
  },
  "types": "dist/index.d.ts",
  "main": "dist/index.js", // CJS
  "module": "dist/index.mjs" // ESM
}

Node, or any bundler, will resolve to the version appropriate depending on the context and environment.

Maintenance

Having ESM & CJS in one package come with a several drawbacks:

For now, we will continue to support both ESM and CJS formats in our Design System. However, I look forward to the day when the Spendesk ecosystem will have completed its migration to ESM, allowing us to simplify our workflow and focus on a single format.