ECMAScript Modules
ECMAScript Modules (ESM) is a specification for using Modules in the Web. It's supported by all modern browsers and the recommended way of writing modular code for the Web.
Webpack supports processing ECMAScript Modules to optimize them.
Exporting
The export keyword allows to expose things from an ESM to other modules:
export const CONSTANT = 42;
export let variable = 42;
// only reading is exposed
// it's not possible to modify the variable from outside
export function fun() {
console.log("fun");
}
export class C extends Super {
method() {
console.log("method");
}
}
let a, b, other;
export { a, b, other as c };
export default 1 + 2 + 3 + more();Importing
The import keyword allows to get references to things from other modules into an ESM:
// import "bindings" to exports from another module
// these bindings are live. The values are not copied,
// instead accessing "variable" will get the current value
// in the imported module
import { CONSTANT, variable } from "./module.js";
// shortcut to import the "default" export
import theDefaultValue from "./module.js";
// import the "namespace object" which contains all exports
import * as module from "./module.js";
module.fun();When importing a namespace object from an ECMAScript Module, webpack follows the ESM convention of setting Symbol.toStringTag to "Module" on the namespace object.
Flagging modules as ESM
By default webpack will automatically detect whether a file is an ESM or a different module system.
Node.js established a way of explicitly setting the module type of files by using a property in the package.json.
Setting "type": "module" in a package.json does force all files below this package.json to be ECMAScript Modules.
Setting "type": "commonjs" will instead force them to be CommonJS Modules.
{
"type": "module"
}In addition to that, files can set the module type by using .mjs or .cjs extension. .mjs will force them to be ESM, .cjs force them to be CommonJs.
In DataURIs using the text/javascript or application/javascript mime type will also force module type to ESM.
In addition to the module format, flagging modules as ESM also affect the resolving logic, interop logic and the available symbols in modules.
import.meta in ESM
Webpack exposes several import.meta properties for use in ESM:
| Property | Description |
|---|---|
import.meta.url | The URL of the current module file - use it for new Worker() or new URL() |
import.meta.webpack | The webpack major version number (e.g. 5) |
import.meta.webpackHot | Equivalent of module.hot - use for HMR in ESM |
import.meta.webpackContext | ESM equivalent of require.context |
Example - using import.meta.url for assets:
// Resolve a sibling file relative to the current module
const iconUrl = new URL("./icon.png", import.meta.url);
const img = document.createElement("img");
img.src = iconUrl.href;Example - HMR in ESM:
if (import.meta.webpackHot) {
import.meta.webpackHot.accept("./module.js", () => {
// handle update
});
}Top-Level Await
In ESM, you can use await at the top level of a module. Webpack treats the module
as an async module automatically. Enabled by default since 5.83.0; the experiments.topLevelAwait option itself was removed in 5.102.0 (it just works).
// user.js (async ESM module)
const response = await fetch("/api/user");
export const user = await response.json();// index.js - importing an async module works as expected
import { user } from "./user.js";
console.log(user.name);Fully Specified Imports
Imports in ESM are resolved more strictly. Relative requests must include a file extension (e.g. *.js or *.mjs) following the Node.js convention when the file is flagged as ESM:
// will fail - missing extension
import { helper as missingExt } from "./utils";
// correct in ESM
import { helper } from "./utils.js";To disable this check (useful when migrating a large CJS codebase), you can use fullySpecified=false:
// webpack.config.js
export default {
module: {
rules: [
{
test: /\.m?js/,
resolve: {
fullySpecified: false,
},
},
],
},
};CommonJS Interop
CommonJS syntax is not available in ESM: require, module, exports, __filename, __dirname.
When webpack bundles a CommonJS module imported from ESM, both the default
import (the entire module.exports object) and named imports (its properties) work:
// esm-consumer.js (ESM)
import cjs from "./cjs-module.js";
import { foo } from "./cjs-module.js";
// cjs-module.js (CommonJS)
module.exports = { foo: 1, bar: 2 };
console.log(cjs.foo); // 1 - cjs is the whole exports object
console.log(foo); // 1 - named imports read properties of module.exportsThis interop applies when webpack treats the imported module as CommonJS.
If that module itself uses ESM export syntax, webpack will auto-detect it as ESM
and use its exports directly. This commonly affects projects that mix .js files
in a project that has "type": "module" set - webpack may treat some files as
ESM while third-party packages in node_modules remain CommonJS.
Dependencies that ship modern syntax
webpack bundles the code of a dependency as it finds it. target and your browserslist configuration constrain the runtime code webpack generates — they never downlevel the syntax you or a package wrote. So a package published with arrow functions, optional chaining or class fields reaches the browser exactly as published, and an older browser fails on it at parse time, with an error pointing into the bundle rather than at the package.
This became more common with webpack 5, which resolves the exports field, so a package that offers both a legacy and a modern build now hands webpack the modern one by default.
There are two ways out, and they are not exclusive.
Transpile the dependency. Guides — this site's build performance page included — recommend keeping loaders off node_modules, which is the right default: it is the bulk of the code and the slowest part to transform. Rather than dropping that rule, carve out the packages that need it:
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
module: {
rules: [
{
test: /\.m?js$/,
include: [
path.resolve(__dirname, "src"),
// plus the dependencies that are not ES5
/node_modules[\\/](pkg-a|pkg-b)[\\/]/,
],
loader: "babel-loader",
},
],
},
};The condition grammar also expresses this the other way round — exclude: { and: [/node_modules/], not: [/node_modules[\\/]pkg-a[\\/]/] } skips node_modules except for one package.
Or resolve a different build of it. When the package still publishes an ES5 bundle under another condition or field, point webpack at it instead of transpiling — see resolve.conditionNames and resolve.mainFields. Both apply to every package webpack resolves, so narrow them with resolve.byDependency or a per-package Rule.resolve rather than changing them globally.
Common Migration Errors
ReferenceError: require is not defined
When a file is treated as ESM, CommonJS globals (require, module, exports,
__filename, __dirname) are unavailable.
Fix: Replace require() with import statements. For conditional or dynamic
loading, use import().
Must use import to load ES Module (Node.js) / SyntaxError: Cannot use import statement in a module (browser)
This happens when a file using ESM import/export syntax is not flagged as ESM -
either "type": "module" is missing from package.json, or the file uses a .js
extension instead of .mjs.
Fix: Add "type": "module" to your package.json, or rename the file to .mjs.
Module not found: Error: Can't resolve './utils' (missing extension)
In ESM, relative imports must include the file extension. Webpack follows the Node.js ESM convention here.
Fix: Change import { helper } from './utils' to import { helper } from './utils.js',
or set fullySpecified: false in your
webpack config to disable the check while migrating.



