I faced a problem recently. I have a reference data package which is an npm package that can be consumed by two different projects:

  • The frontend: a React application bundled with Vite.js, an ESM builder (with no CJS support).
  • The backend: a Nestjs application that only supports CommonJS.

Also, this package contains some big files inside. Bundling everything in one file is a big no-no.

// CommonJS (CJS)
const path = require('path')
module.exports = path.extname('index.html')

// ESM (ECMAScript Modules)
import path from 'path'
export default path.extname('index.html')

Folder structure

Let’s start with a classic file architecture:

├── dist
├── src
│   ├── function-a.ts
│   ├── function-b.ts
│   └── index.ts
├── package.json
├── tsconfig.esm.json
├── tsconfig.cjs.json
└── README.md

Please note that everything under the src/ folder import/export dependencies with the ES7 import / export keyword.

Library’s package.json

The solution I found is to have 2 folders under the dist folder:

{
  "name": "@maxpou/my-cool-package",
  "main": "dist/cjs/index.js",
  "exports": {
    "require": "./dist/cjs/index.js",
    "import": "./dist/esm/index.js"
  },
  "files": ["README.md", "dist"],
  "scripts": {
    "build": "npm run build:cjs && npm run build:esm",
    "build:cjs": "tsc -p tsconfig.cjs.json",
    "build:esm": "tsc -p tsconfig.esm.json"
  }
}

You can see that I use the exports token. This allows the package owner to define multiple entry points. A CommonJS system will use the index located in the ./dist/cjs/index.js folder while an ESM system will use the other index located in the esm folder.

By the way, you can do much more with the conditional exports!

tsconfig.json files

I don’t have one file but two.

// tsconfig.esm.json
{
  "compilerOptions": {
    "target": "esnext",
    "module": "esnext",    "outDir": "./dist/esm",    // ...
  },
  "include": ["src/index.ts"],
  "exclude": ["node_modules"]
}
// tsconfig.cjs.json
{
  "compilerOptions": {
    "target": "es2017",
    "module": "commonjs",    "outDir": "./dist/cjs",    // ...
  },
  "include": ["src/index.ts"],
  "exclude": ["node_modules"]
}

If your IDE or other tools need one named tsconfig.json, you can create a master one. Then the specific tsconfig files can extend from it.

Then you can npm publish and call it a day 🥳

Usage

Your package will be available in both ESM and CommonJS environments. Your users can use it without knowing there’s a separation.

// ESM
import { myFunction } from 'my-cool-package'

// CommonJS
const { myFunction } = require('my-cool-package')
Maxence Poutord

About the author

Hey, I'm Maxence Poutord, a software engineer specialized in web technologies. In my day-to-day job, I'm working as a senior front-end engineer at Orderfox. When I'm not working, you can find me travelling the world or cooking.