Skip to content

ES modules

Cloudpack uses modern ECMAScript modules (ES modules or ESM) for its output and provides an import map for path resolution in the browser.

The first section of this page assumes you're already familiar with ES modules and dives straight into why and how they're used in Cloudpack.

If you're not familiar with ES modules and how they compare to other formats such as CommonJS (CJS), the second section provides details about different module formats and their history. Briefly:

  • CJS is the format historically used by Node, with require() for imports and module.exports for exports. It does not work in browsers.
  • ESM is the modern standard for both browsers and Node, and uses import and export keywords. (ECMAScript is the language specification for JavaScript, and the module specification was added in 2015.)
  • TypeScript uses the import and export keywords, but the final module format is abstracted away by the compiler and/or bundler.

ES modules in Cloudpack

Cloudpack uses ES modules as its output format. This choice is fundamental to its speed and caching strategy.

Why ES modules?

ES modules provide several advantages for Cloudpack's approach:

  1. Fast re-bundles - Only re-bundle the changed package, not the world
  2. Native browser support - Modern browsers can load ES modules directly via <script type="module">, eliminating the need for a module runtime
  3. Import maps - Import maps (<script type="importmap">) translate import paths directly to URLs without an extra transform step
  4. Granular browser caching - Each module can be cached independently in the browser, enabling fine-grained cache invalidation
  5. Static analysis - All imports and exports must be declared upfront, enabling tree shaking and predictable bundling

Library mode output

In library mode, Cloudpack creates one ESM bundle per entry point per package:

node_modules/react/
├── package.json (exports: { ".": "./index.js", "./jsx-runtime": "./jsx-runtime.js" })
└── ...

↓ Cloudpack bundles ↓

~/.cloudpack/react-18.0.0-v0-abc123/index.js
~/.cloudpack/react-18.0.0-v0-abc123/bundled/jsx-runtime.js

Each entry point defined in the package's exports map becomes a separate bundle. If features.removeUnusedExports is enabled, unused entry points are omitted. Common code shared between entry points is automatically extracted into shared chunks to avoid duplication.

During cloudpack start, Cloudpack generates an import map with a mapping from package import paths to bundle server URLs, which the browser uses to translate imports like import React from 'react' into URLs.

Handling non-ESM packages

Many npm packages still publish CommonJS, not ES modules. Cloudpack handles this through:

  1. Bundling - Bundlers like Rollup can convert many CJS patterns to ESM
  2. ESM stubs - Cloudpack generates a wrapper file with the CJS module's exported names, since it's not always possible to determine these through static analysis

See Bundling: ESM stubs for details on how this works.

Best practices

To get the best experience with Cloudpack:

For troubleshooting issues with migrating a repo to use ES modules, see ES module issues.

Module formats and history

ECMAScript is the language specification for all versions of JavaScript (as used by both browsers and Node), but "ES modules" specifically refers to the module format introduced in the 2015 version of the specification.

Before the ES module spec was finalized, Node, browsers, and TypeScript had taken different approaches to implementing or simulating modules. ES modules are usable today in both browsers and Node, but it took nearly a decade to reach that point (and there are still some nuances), so some of the previous formats are still widely used and worth understanding.

Node: CommonJS modules

In Node.js, modules were initially implemented using the CommonJS (CJS) format. require() is used for imports, and exports are defined by assigning to module.exports (or simply to exports).

js
// File: func.js
const foo = require('foo')
const { bar } = require('./bar')

function func() {
  console.log(foo, bar)
}
// could also assign an object or primitive, or assign to module.exports.whatever
module.exports = func

// File: index.js
const func = require('./func')
func()

require() resolves the given string to a path on the local filesystem: either using file locations for relative paths, or searching up through installed node_modules for package names. Neither of these approaches work for the browser, since there's no local filesystem.

An unfortunate feature of CJS modules (from Cloudpack's standpoint) is that module imports and exports can be declared in a massive variety of ways, including with conditional runtime logic:

js
// Please don't do this
const foo = process.env.FOO ? require('foo1') : require('foo2')
if (process.env.WHATEVER) {
  module.exports.foo = 'foo'
} else {
  module.exports.bar = require('bar')
}
module.exports[foo()] = 'umm'

Browsers: no native modules

Originally, browsers didn't support JS modules at all, just synchronously-loaded <script> tags which ran in the global context. Various formats were invented to emulate modules, such as asynchronous module definition (AMD) (note that "today" in the link refers to 2015ish) and universal module definition (UMD). Generally you can recognize these by references to define().

AMD and UMD patterns are still seen in 2025, most often as bundle output generated by tools such as webpack, but you should not use them directly in new code!

CJS modules are sometimes used as an intermediate format for code that's primarily intended to run in the browser, but will be bundled (such as by Webpack) before running.

Introducing ES modules

The 2015 version of the ECMAScript specification (also called ES2015 or ES6) introduced proper support for modules, which use the import and export keywords:

js
// File: func.js
import foo from 'foo'
import { bar } from './bar'

export default function func() {
  console.log(foo, bar)
}

// File: index.js
import func from './func.js'
func()

In Node, imported paths are resolved on the filesystem using a similar algorithm as with CJS. In the browser, an entry point ES module is loaded with <script type="module">, and the new feature of import maps facilitates import path resolution without a filesystem.

Unlike in CJS modules, all export names must be statically defined, and standard imports are hoisted to the top of the file. This makes things much easier for a bundler to analyze.

Although ES modules are "the future," it's taken the better part of a decade for native support in either browsers or Node to become widespread enough that the format is directly usable (and there are still some quirks and nuances). So it's still very common to see CommonJS for Node, and various legacy formats for browser bundle output.

TypeScript

TypeScript uses the import/export keywords with syntax similar to ESM, and its module setting allows transpilation to a variety of output script/module formats.

Unfortunately, TS initially implemented the import/export keywords before the ES2015 spec was finalized, so there are a few differences in the original approach that continue to cause headaches. You can choose between the original approach and the spec-compliant approach with the esModuleInterop flag. For more details about issues and migration, see the ES module issues docs.