Configuration is always a chore, but an unfortunately necessary evil. And configuring a package for CommonJS (CJS) and ES Modules (ESM) can be a waking nightmare—not least because it has changed a dozen times in half as many years.
As one of the implementers for Node.js Loaders, touching much of Node’s internal ESM code, I get asked quite frequently “how do I make this work!?” (often with angry tears); but yet more frequently I come across packages that are just misconfigured.
My name is Jacob, and I’m here to help.
I have confirmed all the provided package.json
configurations (not specifically marked “does not work”) work in Node.js 12.22.x (v12 latest, the oldest supported line) and 17.2.0 (current latest at the time)1, and for grins, with webpack 5.53.0 and 5.63.0 respectively. I’ve prepared a repository with them so you can check them out yourself: JakobJingleheimer/nodejs-module-config-examples (the repo’s root README explains how to use it).
For curious cats, Preamble: How did we get here and Down the rabbit-hole provide background and deeper explanations. If you're just looking for a solution, jump to Pick your poison for the TLDR.
Preamble: How did we get here
CommonJS (CJS) was created long before ECMAScript Modules (ESM), back when JavaScript was still adolescent—CJS and jQuery were created just 3 years apart. CJS is not an official (TC39) standard and is supported by a limited few platforms (most notably, Node.js). ESM as a standard has been incoming for several years; it is currently supported by all major platforms (browsers, Deno, Node.js, etc), meaning it will run pretty much everywhere. As it became clear ESM would effectively succeed CJS (which is still very popular and widespread), many attempted to adopt early on, often before a particular aspect of the ESM specification was finalised. Because of this, those changed over time as better information became available (often informed by learnings/experiences of those eager beavers), going from best-guess to the aligning with the specification.
An additional complication is bundlers, which historically managed much of this territory. However, much of what we previously needed bundle(r)s to manage is now native functionality; yet bundlers are still (and likely always will be) necessary for some things. Unfortunately, functionality bundlers no-longer need to provide is deeply ingrained in older bundlers’ implementations, so they can at times be too helpful, and in some cases, anti-pattern (bundling a library is often not recommended by bundler authors themselves). The hows and whys of that are an article unto itself.
Pick your poison
This article covers configuration of all possible combinations in modern Node.js (v12+). If you are trying to decide which options are ideal, it is better to avoid dual packages, so either:
- ESM source and distribution
- CJS source and distribution with good/specific
module.exports
You as a package author write | Consumers of your package write their code in | Your options |
---|---|---|
CJS source code using require()
|
CJS: consumers require() your package |
CJS source and distribution |
CJS source code using require()
|
ESM: consumers import your package |
CJS source and only ESM distribution |
CJS source code using require()
|
CJS & ESM: consumers either require() or import your package |
CJS source and both CJS & ESM distribution |
ESM source code using import
|
CJS: consumers require() your package |
ESM source with only CJS distribution |
ESM source code using import
|
ESM: consumers import your package |
ESM source and distribution |
ESM: source code uses import
|
CJS & ESM: consumers either require() or import your package |
ESM source and both CJS & ESM distribution |
CJS source and distribution
This the "Rum & Coke" of packages: pretty difficult to mess up. Essentially just declare the package’s exports via the "exports"
field/field-set.
Working example: cjs-with-cjs-distro
{
"type": "commonjs", // current default, but may change
"engines": { "node": ">=12.22.7" }, // optional, but kind
"exports": {
".": "PATH/TO/DIST/CODE/ENTRYPOINT.js", // ex "./dist/index.js"
"./package.json": "./package.json" // ensure this file is importable
}
}
Note that packageJson.exports["."] = filepath
is shorthand for packageJson.exports["."].default = filepath
CJS source and only ESM distribution
The "Gin & Tonic" of packages: This takes a small bit of finesse but is also pretty straight-forward.
Working example: cjs-with-esm-distro
{
"type": "commonjs", // current default, but may change
"engines": { "node": ">=12.22.7" }, // optional, but kind
"exports": {
".": "PATH/TO/DIST/CODE/ENTRYPOINT.mjs", // ex "./dist/index.mjs"
"./package.json": "./package.json" // ensure this file is importable
}
}
The .mjs
file extension is a trump-card: it will override any other configuration and the file will be treated as ESM. Using this file extension is necessary because packageJson.exports.import
does NOT signify that the file is ESM (contrary to common, if not universal, misperception), only that it is the file to be used when the package is imported (ESM can import CJS. See Gotchas below).
The "engines"
field provides both a human-friendly and a machine-friendly indication of with which version(s) of Node.js the package is compatible. Depending on the package manager used, an exception may be thrown causing the installation to fail when the consumer is using an incompatible version of Node.js (which can be very helpful to consumers). Including this field here will save a lot of headache for consumers with an older version of Node.js who cannot use the package.
CJS source and both CJS & ESM distribution
You have a few options:
Attach named exports directly onto exports
The "French 75" of packages: Classic but takes some sophistication and finesse.
Pros:
- Smaller package weight
- Easy and simple (probably least effort if you don't mind keeping to a minor syntax stipulation)
- Precludes the Dual-Package Hazard
Cons:
- Hacky-ish: Leverages non-explicitly documented behaviour in Node.js's algorithm (it can but is very unlikely to change).
- Requires very specific syntax (either in source code and/or bundler gymnastics).
Working example: cjs-with-dual-distro (properties)
{
"type": "commonjs", // current default, but may change
"engines": { "node": ">=12.22.7" }, // optional, but kind
"exports": {
".": "PATH/TO/DIST/CJS-CODE/ENTRYPOINT.js", // ex "./dist/cjs/index.js"
"./package.json": "./package.json" // ensure this file is importable
}
}
Typically, you would see module.exports
assigned to something (be it an object or a function) like this:
const someObject = {
foo() {},
bar() {},
qux() {},
};
module.exports = someObject;
Instead, do this:
module.exports.foo = function foo() {}
module.exports.foo = function bar() {}
module.exports.foo = function qux() {}
Use a simple ESM wrapper
The "Piña Colada" of packages: Complicated setup and difficult to get the balance right.
Pros:
- Smaller package weight
Cons:
- Likely requires complicated bundler gymnastics (I could not find any existing option to automate this in Webpack).
Working example: cjs-with-dual-distro (wrapper)
{
"type": "commonjs", // current default, but may change
"engines": { "node": ">=12.22.7" }, // optional, but kind
"exports": {
".": {
"import": "PATH/TO/DIST/ESM-CODE/ENTRYPOINT.mjs", // ex "./dist/es/wrapper.mjs"
"require": "PATH/TO/DIST/CJS-CODE/ENTRYPOINT.js", // ex "./dist/cjs/index.js"
"default": "PATH/TO/DIST/CJS-CODE/ENTRYPOINT.js" // ex "./dist/cjs/index.js"
},
"./package.json": "./package.json" // ensure this file is importable
}
}
In order to support named exports from the CJS bundle for an ESM consumer, this will need a bit of gymnastics from a bundler but is conceptually very simple.
In certain conditions, CJS exports an object (which gets aliased to ESM's default
); that object, like any object, is destructure-able. You can leverage that to pluck all the members of the object out, and then re-export them so the ESM consumer is none the wiser.
// ./dist/es/wrapper.mjs
import cjs from '../cjs/index.js';
const { a, b, c, /* … */ } = cjs;
export { a, b, c, /* … */ };
Two full distributions
The "Long Island Ice Tea" of packages: Chuck in a bunch of stuff and hope for the best. This is probably the most common and easiest of the CJS to CJS & ESM options, but you pay for it.
Pros:
- Simple bundler configuration
Cons:
- Larger package weight (basically double)
Working example: cjs-with-dual-distro (double)
{
"type": "commonjs", // current default, but may change
"engines": { "node": ">=12.22.7" }, // optional, but kind
"exports": {
".": {
"import": "PATH/TO/DIST/ESM-CODE/ENTRYPOINT.mjs", // ex "./dist/es/index.mjs"
"require": "PATH/TO/DIST/CJS-CODE/ENTRYPOINT.js", // ex "./dist/cjs/index.js"
"default": "PATH/TO/DIST/CJS-CODE/ENTRYPOINT.js" // ex "./dist/cjs/index.js"
},
"./package.json": "./package.json" // ensure this file is importable
}
}
ESM source and distribution
The wine of packages: Simple, tried, and true.
This is almost exactly the same as the CJS-CJS configuration above with 1 small difference: the "type"
field.
Working example: esm-with-esm-distro
{
"type": "module",
"engines": { "node": ">=12.22.7" }, // optional, but kind
"exports": {
".": "PATH/TO/DIST/CODE/ENTRYPOINT.js", // ex "./dist/index.js"
"./package.json": "./package.json" // ensure this file is importable
}
}
Note that ESM is not “backwards” compatible with CJS: a CJS module cannot require()
an ES Module; it is possible to use a dynamic import (await import()
), but this is likely not what consumers expect (and, unlike ESM, CJS does not support Top-Level Await).
ESM source with only CJS distribution
We're not in Kansas anymore, Toto.
The configurations (there are 2 options) are nearly the same as ESM source and both CJS & ESM distribution, just exclude packageJson.exports.import
.
💡 Using "type": "module"
2 paired with the .cjs
file extension (for commonjs files) yields best results. For more information on why, see Down the rabbit-hole and Gotchas below.
Working example: esm-with-cjs-distro
ESM source and both CJS & ESM distribution
These are "mixologist" territory.
When source code is written in non-JavaScript (ex TypeScript), options can be limited due to needing to use file extension(s) specific to that language (ex .ts
) and there is often no .mjs
equivalent3.
Similar to CJS source and both CJS & ESM distribution, you have the same options.
There is also a 4th option of publishing only an ESM distribution and forcing consumers to use a dynamic import (await import()
), but that is not quite the same and will likely lead to angry consumers, so it is not covered here.
Publish only a CJS distribution with property exports
The "Mojito" of packages: Tricky to make and needs good ingredients.
This option is almost identical to the CJS source with CJS & ESM distribution's property exports above. The only difference is in package.json: "type": "module"
.
Only some build tools support generating this output. Rollup produces compatible output out of the box when targetting commonjs. Webpack as of v5.66.0+ does with the new commonjs-static
output type, (prior to this no commonjs options produces compatible output). It is not currently possible with esbuild (which produces a non-static exports
).
The working example below was created prior to Webpack's recent release, so it uses Rollup (I'll get around to adding a Webpack option too).
Working example: esm-with-cjs-distro
{
"type": "module",
"engines": { "node": ">=12.22.7" }, // optional, but kind
"exports": {
".": "PATH/TO/DIST/CJS-CODE/ENTRYPOINT.cjs", // ex "./dist/index.cjs"
"./package.json": "./package.json" // ensure this file is importable
}
}
💡 Using "type": "module"
2 paired with the .cjs
file extension (for commonjs files) yields best results. For more information on why, see Down the rabbit-hole and Gotchas below.
Publish a CJS distribution with an ESM wrapper
The "Pornstar Martini" of packages: There's a lot going on here.
This is also almost identical to the CJS source and dual distribution using an ESM wrapper, but with subtle differences "type": "module"
and some .cjs
file extenions in package.json.
Working example: esm-with-dual-distro (wrapper)
{
"type": "module",
"engines": { "node": ">=12.22.7" }, // optional, but kind
"exports": {
".": {
"import": "PATH/TO/DIST/ESM-CODE/ENTRYPOINT.js", // ex "./dist/es/wrapper.js"
"require": "PATH/TO/DIST/CJS-CODE/ENTRYPOINT.cjs", // ex "./dist/cjs/index.cjs"
"default": "PATH/TO/DIST/CJS-CODE/ENTRYPOINT.cjs" // ex "./dist/cjs/index.cjs"
},
"./package.json": "./package.json" // ensure this file is importable
}
}
💡 Using "type": "module"
2 paired with the .cjs
file extension (for commonjs files) yields best results. For more information on why, see Down the rabbit-hole and Gotchas below.
Publish both full CJS & ESM distributions
The "Tokyo Tea" of packages: Chuck in a bunch of stuff (with a surprise) and hope for the best. This is probably the most common and easiest of the ESM to CJS & ESM options, but you pay for it.
In terms of package configuration, there are a few options that differ mostly in personal preference.
Mark the whole package as ESM and specifically mark the CJS exports as CJS via the .cjs
file extension
This option has the least burden on development/developer experience.
This also means that whatever build tooling must produce the distribution file with a .cjs
file extension. This might necessitate chaining multiple build tools or adding a subsequent step to move/rename the file to have the .cjs
file extension (ex mv ./dist/index.js ./dist/index.cjs
)3. This can be worked around by adding a subsequent step to move/rename those outputted files (ex Rollup or a simple shell script).
Support for the .cjs
file extension was added in 12.0.0, and using it will cause ESM to properly recognised a file as commonjs (import { foo } from './foo.cjs
works). However, require()
does not auto-resolve .cjs
like it does for .js
, so file extension cannot be omitted as is commonplace in commonjs: require('./foo')
will fail, but require('./foo.cjs')
works. Using it in your package's exports has no drawbacks: packageJson.exports
(and packageJson.main
) requires a file extension regardless, and consumers reference your package by the "name"
field of your package.json (so they're blissfully unaware).
Working example: esm-with-dual-distro
{
"type": "module",
"engines": { "node": ">=12.22.7" }, // optional, but kind
"exports": {
".": {
"import": "PATH/TO/DIST/ESM-CODE/ENTRYPOINT.js", // ex "./dist/es/index.js"
"require": "PATH/TO/DIST/CJS-CODE/ENTRYPOINT.cjs" // ex "./dist/index.cjs"
},
"./package.json": "./package.json" // ensure this file is importable
}
}
💡 Using "type": "module"
2 paired with the .cjs
file extension (for commonjs files) yields best results. For more information on why, see Down the rabbit-hole and Gotchas below.
Use the .mjs
(or equivalent) file extension for all source code files
The configuration for this is the same as CJS source and both CJS & ESM distribution.
Non-JavaScript source code: The non-JavaScript language’s own configuration needs to recognise/specify that the input files are ESM.
Node.js before 12.22.x
🛑 You should not do this: Versions of Node.js prior to 12.x are End of Life and are now vulnerable to serious security exploits.
If you're a security researcher needing to investigate Node.js prior to v12.22.x, feel free to contact me for help configuring.
Down the rabbit-hole
Specifically in relation to Node.js, there are 4 problems to solve:
- Determining format of source code files (author running her/his own code)
Determining format of distribution files (code consumers will receive)
Publicising distribution code for when it is
require()
’d (consumer expects CJS)Publicising distribution code for when it is
import
’d (consumer probably wants ESM)
⚠️ The first 2 are independent of the last 2.
The method of loading does NOT determine the format the file is interpreted as:
-
package.json’s
exports.require
≠CJS
.require()
does NOT and cannot blindly interpret the file as CJS; for instance,require('foo.json')
correctly interprets the file as JSON, not CJS. The module containing therequire()
call of course must be CJS, but what it is loading is not necessarily also CJS. -
package.json’s
exports.import
≠ESM
.import
similarly does NOT and cannot blindly interpret the file as ESM;import
can load CJS, JSON, and WASM, as well as ESM. The module containing theimport
statement of course must be ESM, but what it is loading is not necessarily also ESM.
So when you see configuration options citing or named with require
or import
, resist the urge to assume they are for determining CJS vs ES Modules.
⚠️ Adding an "exports"
field/field-set to a package’s configuration effectively blocks deep pathing into the package for anything not explicitly listed in the exports’ subpathing. This means it can be a breaking change.
⚠️ Consider carefully whether to distribute both CJS and ESM: It creates the potential for the Dual Package Hazard (especially if misconfigured and the consumer tries to get clever). This can lead to an extremely confusing bug in consuming projects, especially when your package is not perfectly configured. Consumers can even be blind-sided by an intermediary package that uses the "other" format of your package (eg consumer uses the ESM distribution, and some other package the consumer is also using itself uses the CJS distribution). If your package is in any way stateful, consuming both the CJS and ESM distributions will result in parallel states (which is almost surely unintentional).
Gotchas
The package.json
's "type"
field changes the .js
file extension to mean either commonjs
or ES module
respectively. It is very common in dual/mixed packages (that contain both CJS and ESM) to use this field incorrectly.
// ⚠️ THIS DOES NOT WORK
{
"type": "module",
"main": "PATH/TO/DIST/CJS-CODE/ENTRYPOINT.js",
"exports": {
".": {
"import": "PATH/TO/DIST/ESM-CODE/ENTRYPOINT.js",
"require": "PATH/TO/DIST/CJS-CODE/ENTRYPOINT.js",
"default": "PATH/TO/DIST/CJS-CODE/ENTRYPOINT.js"
},
"./package.json": "./package.json"
}
}
This does not work because "type": "module"
causes packageJson.main
, packageJson.exports["."].require
, and packageJson.exports["."].default
to get interpreted as ESM (but they’re actually CJS).
Excluding "type": "module"
produces the opposite problem:
// ⚠️ THIS DOES NOT WORK
{
"main": "PATH/TO/DIST/CJS-CODE/ENTRYPOINT.js",
"exports": {
".": {
"import": "PATH/TO/DIST/ESM-CODE/ENTRYPOINT.js",
"require": "PATH/TO/DIST/CJS-CODE/ENTRYPOINT.js",
"default": "PATH/TO/DIST/CJS-CODE/ENTRYPOINT.js"
},
"./package.json": "./package.json"
}
}
This does not work because packageJson.exports["."].import
will get interpreted as CJS (but it’s actually ESM).
Footnotes
- There was a bug in Node.js v13.0–13.6 where
packageJson.exports["."]
had to be an array with verbose config options as the first item (as an object) and the “default” as the second item (as a string). See nodejs/modules#446. - The
"type"
field in package.json changes what the.js
file extension means, similar to to an HTML script element’s type attribute. - TypeScript has experimental support for the package.json
"type"
field and.cts
and.mts
file extensions.
Thank you to @geoffreybooth, @guybedford, @ljharb, @jwfwessels, and @sokra.
Top comments (0)