How to publish an npm package for ESM and CommonJS with TypeScript

A step by step tutorial on how to publish an npm package for ECMAScript Modules (ESM) and CommonJS (CJS) with TypeScript

Maxence Poutord
2 min read
Cover for How to publish an npm package for ESM and CommonJS with TypeScript

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:

Terminal window
β”œβ”€β”€ 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

Written by

Maxence Poutord

Hey, I'm Maxence Poutord, a senior software engineer at Orderfox. I'm also an indie hacker behind Pedalyze and YoutubeMate. When I'm not shipping, you can find me cooking, cycling or running. I'm also on Twitter, Bluesky, GitHub, and LinkedIn.

Keep reading

Recommended posts