Publishing a package
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. These are available: JakobJingleheimer/nodejs-module-config-examples.
For curious cats, How did we get here and Down the rabbit-hole provide background and deeper explanations.
Pick your fix
There are 2 main options, which cover almost all use-cases:
- Write source code and publish in CJS (you use
require()
); CJS is consumable by both CJS and ESM (in all versions of node). Skip to CJS source and distribution. - Write source code and publish in ESM (you use
import
, and don't use top-levelawait
); ESM is consumable by both ESM and CJS (in node 22.x and 23.x; seerequire()
an ES Module). Skip to ESM source and distribution.
It's generally best to publish only 1 format, either CJS or ESM. Not both. Publishing multiple formats can result in the dual-package hazard, as well as other drawbacks.
There are other options available, mostly for historical purposes.
You as a package author write | Consumers of your package write their code in | Your options |
---|---|---|
CJS source code using require() | ESM: consumers import your package | CJS source and only ESM distribution |
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 (and you use top-level await ) | ESM source with only CJS distribution |
CJS & ESM: consumers either require() or import your package | ESM source and both CJS & ESM distribution |
CJS source and distribution
The most minimal configuration may be only "name"
. But the less arcane, the better: Essentially just declare the package’s exports via the "exports"
field/field-set.
Working example: cjs-with-cjs-distro
{
"name": "cjs-source-and-distribution"
// "main": "./index.js"
}
Note that packageJson.exports["."] = filepath
is shorthand for packageJson.exports["."].default = filepath
ESM source and distribution
Simple, tried, and true.
Note that since Node.js v23.0.0, it is possible to require
static ESM (code that does not use top-level await
). See Loading ECMAScript modules using require()
for details.
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
{
"name": "esm-source-and-distribution",
"type": "module"
// "main": "./index.js"
}
Note that ESM now is “backwards” compatible with CJS: a CJS module now can require()
an ES Module without a flag as of 23.0.0 and 22.12.0.
CJS source and only ESM distribution
This takes a small bit of finesse but is also pretty straight-forward. This may be the choice pick of older projects targetting newer standards, or authors who merely prefer CJS but are publishing for a different environment.
Working example: cjs-with-esm-distro
{
"name": "cjs-source-with-esm-distribution",
"main": "./dist/index.mjs"
}
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).
CJS source and both CJS & ESM distribution
In order to directly supply both audiences (so that your distribution works "natively" in either), you have a few options:
Attach named exports directly onto exports
Classic but takes some sophistication and finesse. This means adding properties onto the existing module.exports
(instead of re-assigning module.exports
as a whole).
Working example: cjs-with-dual-distro (properties)
{
"name": "cjs-source-with-esm-via-properties-distribution",
"main": "./dist/cjs/index.js"
}
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:
- Requires very specific syntax (either in source code and/or bundler gymnastics).
Sometimes, a CJS module may re-assign module.exports
to something else (be it an object or a function) like this:
const someObject = {
foo() {},
bar() {},
qux() {},
};
module.exports = someObject;
Node.js detects the named exports in CJS via static analysis that look for certain patterns, which the example above evades. To make the named exports detectable, do this:
module.exports.foo = function foo() {};
module.exports.bar = function bar() {};
module.exports.qux = function qux() {};
Use a simple ESM wrapper
Complicated setup and difficult to get the balance right.
Working example: cjs-with-dual-distro (wrapper)
{
"name": "cjs-with-wrapper-dual-distro",
"exports": {
".": {
"import": "./dist/esm/wrapper.mjs",
"require": "./dist/cjs/index.js",
"default": "./dist/cjs/index.js"
}
}
}
Pros:
- Smaller package weight
Cons:
- Likely requires complicated bundler gymnastics (we could not find any existing option to automate this in Webpack).
When the CJS output from the bundler evades the named exports detection in Node.js, a ESM wrapper can be used to explicitly re-export the known named exports for ESM consumers.
When CJS exports an object (which gets aliased to ESM's default
), you can save references to all the members of the object locally in the wrapper, and then re-export them so the ESM consumer can access all of them by name.
import cjs from '../cjs/index.js';
const { a, b, c /* … */ } = cjs;
export { a, b, c /* … */ };
However, this does break live bindings: a reassignment to cjs.a
will not reflect in esmWrapper.a
.
Two full distributions
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. This is rarely a good idea.
Working example: cjs-with-dual-distro (double)
{
"name": "cjs-with-full-dual-distro",
"exports": {
".": {
"import": "./dist/esm/index.mjs",
"require": "./dist/cjs/index.js",
"default": "./dist/cjs/index.js"
}
}
}
Pros:
- Simple bundler configuration
Cons:
- Larger package weight (basically double)
- Vulnerable to the Dual-Package Hazard
Alternatively, you can use "default"
and "node"
keys, which are less counter-intuitive: Node.js will always choose the "node"
option (which always works), and non-Node.js tooling will choose "default"
when configured to target something other than node. This precludes the dual-package hazard.
{
"name": "cjs-with-alt-full-dual-distro",
"exports": {
".": {
"node": "./dist/cjs/index.js",
"default": "./dist/esm/index.mjs"
}
}
}
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
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 may be no .mjs
equivalent.
Similar to CJS source and both CJS & ESM distribution, you have the same options.
Publish only a CJS distribution with property exports
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).
These examples assume javascript files within use the extension .js
; "type"
in package.json
controls how those are interpreted:
"type":"commonjs"
+ .js
→ cjs
"type":"module"
+ .js
→ mjs
If your files explicitly all use .cjs
and/or .mjs
file extensions (none use .js
), "type"
is superfluous.
Working example: esm-with-cjs-distro
{
"name": "esm-with-cjs-distribution",
"type": "module",
"main": "./dist/index.cjs"
}
💡 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
There's a lot going on here, and this is usually not the best.
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)
{
"name": "esm-with-cjs-and-esm-wrapper-distribution",
"type": "module",
"exports": {
".": {
"import": "./dist/esm/wrapper.js",
"require": "./dist/cjs/index.cjs",
"default": "./dist/cjs/index.cjs"
}
}
}
💡 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
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. This is rarely a good idea.
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
). 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",
"exports": {
".": {
"import": "./dist/esm/index.js",
"require": "./dist/index.cjs"
}
}
}
Alternatively, you can use "default"
and "node"
keys, which are less counter-intuitive: Node.js will always choose the "node"
option (which always works), and non-Node.js tooling will choose "default"
when configured to target something other than node. This precludes the dual-package hazard.
{
"type": "module",
"exports": {
".": {
"node": "./dist/index.cjs",
"default": "./dist/esm/index.js"
}
}
}
💡 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 us for help configuring.
General notes
Syntax detection is not a replacement for proper package configuration; syntax detection is not fool-proof and it has significant performance cost.
When using "exports"
in package.json, it is generally a good idea to include "./package.json": "./package.json"
so that it can be imported (module.findPackageJSON
is not affected by this limitation, but import
may be more convenient).
"exports"
can be advisable over "main"
because it prevents external access to internal code (so you can be relatively sure users are not depending on things they shouldn't). If you don't need that, "main"
is simpler and may be a better option for you.
The "engines"
field provides both a human-friendly and a machine-friendly indication of 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 will save a lot of headache for consumers with an older version of Node.js who cannot use the package.
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).
The dual-package hazard
When an application is using a package that provides both CommonJS and ES module sources, there is a risk of certain bugs if both instances of the package get loaded. This potential comes from the fact that the pkgInstance
created by const pkgInstance = require('pkg')
is not the same as the pkgInstance
created by import pkgInstance from 'pkg'
(or an alternative main path like 'pkg/module'
). This is the “dual package hazard”, where two instances of the same package can be loaded within the same runtime environment. While it is unlikely that an application or package would intentionally load both instances directly, it is common for an application to load one copy while a dependency of the application loads the other copy. This hazard can happen because Node.js supports intermixing CommonJS and ES modules, and can lead to unexpected and confusing behavior.
If the package main export is a constructor, an instanceof
comparison of instances created by the two copies returns false
, and if the export is an object, properties added to one (like pkgInstance.foo = 3
) are not present on the other. This differs from how import
and require
statements work in all-CommonJS or all-ES module environments, respectively, and therefore is surprising to users. It also differs from the behavior users are familiar with when using transpilation via tools like Babel or esm
.
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.
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.
{
"type": "module",
"main": "./dist/CJS/index.js",
"exports": {
".": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js",
"default": "./dist/cjs/index.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:
{
"main": "./dist/CJS/index.js",
"exports": {
".": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js",
"default": "./dist/cjs/index.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. ↩ ↩2 ↩3 ↩4