Three years ago, I wrote a post about shipping ESM & CJS in a single package, advocating for dual CJS/ESM formats to ease user migration and trying to make the best of both worlds. Back then, I didn’t fully agree with aggressively shipping ESM-only, as I considered the ecosystem wasn’t ready, especially since the push was mostly from low-level libraries. Over time, as tools and the ecosystem have evolved, my perspective has gradually shifted towards more and more on adopting ESM-only.
As of 2025, a decade has passed since ESM was first introduced in 2015. Modern tools and libraries have increasingly adopted ESM as the primary module format. According to WOOORM’s script, the packages that ships ESM on npm in 2021 was 7.8%, and by the end of 2024, it had reached 25.8%. Although a significant portion of packages still use CJS, the trend clearly shows a good shift towards ESM.
npm-esm-vs-cjs
script. Last updated at 2024-11-27Here in this post, I’d like to share my thoughts on the current state of the ecosystem and why I believe it’s time to move on to ESM-only.
Modern Tools
With the rise of Vite as a popular modern frontend build tool, many meta-frameworks like Nuxt, SvelteKit, Astro, SolidStart, Remix, Storybook, Redwood, and many others are all built on top of Vite nowadays, that treating ESM as a first-class citizen.
As a complement, we have also testing library Vitest, which was designed for ESM from the day one with powerful module mocking capability and efficient fine-grain caching support.
CLI tools like tsx
and jiti
offer a seamless experience for running TypeScript and ESM code without requiring additional configuration. This simplifies the development process and reduces the overhead associated with setting up a project to use ESM.
Other tools, for example, ESLint, in the recent v9.0, introduced a new flat config system that enables native ESM support with eslint.config.mjs
, even in CJS projects.
Top-Down & Bottom-Up
Back in 2021, when SINDRESORHUS first started migrating all his packages to ESM-only, for example, find-up
and execa
, it was a bold move. I consider this move as a bottom-up approach, as the packages that rather low-level and many their dependents are not ready for ESM yet. I was worried that this would force those dependents to stay on the old version of the packages, which might result in the ecosystem being fragmented. (As of today, I actually appreciate that move bringing us quite a lot of high-quality ESM packages, regardless that the process wasn’t super smooth).
It’s way easier for an ESM or Dual formats package to depend on CJS packages, but not the other way around. In terms of smooth adoption, I believe the top-down approach is more effective in pushing the ecosystem forward. With the support of high-level frameworks and tools from top-down, it’s no longer a significant obstacle to use ESM-only packages. The remaining challenges in terms of ESM adoption primarily lie with package authors needing to migrate and ship their code in ESM format.
Requiring ESM in Node.js
The capability to require()
ESM modules in Node.js, initiated by JOYEECHEUNG, marks an incredible milestone. This feature allows packages to be published as ESM-only while still being consumable by CJS codebases with minimal modifications. It helps avoid the async infection (also known as Red Functions) introduced by dynamic import()
ESM, which can be pretty hard, if not impossible in some cases, to migrate and adapt.
This feature was recently unflagged and backported to Node.js v22 (and soon v20), which means it should be available to many developers already. Consider the top-down or bottom-up metaphor, this feature actually makes it possible to start ESM migration also from middle-out, as it allows import chains like ESM → CJS → ESM → CJS
to work seamlessly.
To solve the interop issue between CJS and ESM in this case, Node.js also introduced a new export { Foo as 'module.exports' }
syntax in ESM to export CJS-compatible exports (by this PR). This allows package authors to publish ESM-only packages while still supporting CJS consumers, without even introducing breaking changes (expcet for changing the required Node.js version).
For mor