Printable
Guides
This section contains guides for understanding and mastering the wide variety of tools and features that webpack offers. The first is a guide that takes you through getting started.
The guides get more advanced as you go on. Most serve as a starting point, and once completed you should feel more comfortable diving into the actual documentation.
Getting Started
Webpack is a good fit when your application needs a customizable build pipeline: bundling JavaScript modules, processing assets, integrating loaders and plugins, and shaping output for different environments. For a very small page with one or two scripts, a bundler may be unnecessary at first; for an application with shared dependencies, npm packages, assets, and production builds, webpack gives you explicit control over how everything is assembled.
Webpack is used to efficiently compile JavaScript modules. Once installed, you can interact with webpack either from its CLI or API. If you're still new to webpack, please read through the core concepts and this comparison to learn why you might use it over the other tools that are out in the community.
Quick Start (Minimal Working Example)
If you want to get a working webpack project up and running quickly, the easiest way is to scaffold one using create-webpack-app.
npx create-webpack-app webpack-demo
cd webpack-demoBasic Setup
First let's create a directory, initialize npm, install webpack locally, and install the webpack-cli (the tool used to run webpack on the command line):
# Run the commands for one package manager only.
mkdir webpack-demo
cd webpack-demo
# npm
npm init -y
npm install webpack webpack-cli --save-dev
# yarn
yarn init -y
yarn add webpack webpack-cli --dev
# pnpm
pnpm init
pnpm add webpack webpack-cli -DThroughout the Guides we will use diff blocks to show you what changes we're making to directories, files, and code. For instance:
+ this is a new line you shall copy into your code
- and this is a line to be removed from your code
and this is a line not to touch.Now we'll create the following directory structure, files and their contents:
project
webpack-demo
├── package.json
├── package-lock.json
+ ├── index.html
+ └── src/
+ └── index.jssrc/index.js
function component() {
const element = document.createElement("div");
// Lodash, currently included via a script, is required for this line to work
element.innerHTML = _.join(["Hello", "webpack"], " ");
return element;
}
document.body.appendChild(component());index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Getting Started</title>
<script src="https://unpkg.com/lodash@4.17.21"></script>
</head>
<body>
<script src="./src/index.js"></script>
</body>
</html>We also need to adjust our package.json file in order to make sure we mark our package as private, as well as removing the main entry. This is to prevent an accidental publish of your code.
We also add "type": "module" so that Node.js treats .js files in this project as ES modules. That setting applies project-wide, including future Node.js scripts and webpack configuration files. If you would rather keep Node's default CommonJS behavior, omit "type": "module" and write the configuration later in this guide with require(...) and module.exports instead of import and export default.
package.json
{
"name": "webpack-demo",
"version": "1.0.0",
"description": "",
- "main": "index.js",
+ "private": true,
+ "type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "MIT",
"devDependencies": {
"webpack": "^5.105.0",
"webpack-cli": "^7.0.0"
}
}In this example, there are implicit dependencies between the <script> tags. Our index.js file depends on lodash being included in the page before it runs. This creates an implicit dependency on a global variable (_), making script execution order critical and harder to maintain.
There are problems with managing JavaScript projects this way:
- It is not immediately apparent that the script depends on an external library.
- If a dependency is missing, or included in the wrong order, the application will not function properly.
- If a dependency is included but not used, the browser will be forced to download unnecessary code.
Webpack solves these issues by explicitly declaring dependencies and bundling them together. This removes reliance on global variables and ensures scripts are executed in the correct order.
Creating a Bundle
First we'll tweak our directory structure slightly, separating the "source" code (./src) from our "distribution" code (./dist). The "source" code is the code that we'll write and edit. The "distribution" code is the minimized and optimized output of our build process that will eventually be loaded in the browser. Tweak the directory structure as follows:
project
webpack-demo
├── package.json
├── package-lock.json
+ ├── /dist
+ │ └── index.html
- ├── index.html
└── /src
└── index.jsThe dist directory is build output, so you usually do not hand-edit files there in a mature project. We are moving index.html into dist for now only as temporary scaffolding, so the browser has an HTML file that loads the first generated bundle. Later on in another guide, we will generate index.html rather than edit it manually. Once this is done, it should be safe to empty the dist directory and regenerate all the files within it.
To bundle the lodash dependency with index.js, we'll need to install the library locally:
# Run the command for one package manager only.
# npm
npm install lodash
# yarn
yarn add lodash
# pnpm
pnpm add lodashNow, let's import lodash in our script:
src/index.js
+import _ from 'lodash';
+
function component() {
const element = document.createElement('div');
- // Lodash, currently included via a script, is required for this line to work
+ // Lodash, now imported by this script
element.innerHTML = _.join(['Hello', 'webpack'], ' ');
return element;
}
document.body.appendChild(component());Now, since we'll be bundling our scripts, we have to update our index.html file. Let's remove the lodash <script>, as we now import it, and modify the other <script> tag to load the bundle, instead of the raw ./src file:
dist/index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Getting Started</title>
- <script src="https://unpkg.com/lodash@4.17.21"></script>
</head>
<body>
- <script src="./src/index.js"></script>
+ <script src="main.js"></script>
</body>
</html>In this setup, index.js explicitly requires lodash to be present, and binds it as _ (no global scope pollution). By stating what dependencies a module needs, webpack can use this information to build a dependency graph. It then uses the graph to generate an optimized bundle where scripts will be executed in the correct order.
With that said, let's run npx webpack from the project root. If webpack is installed locally, npx will run the local binary from node_modules/.bin; otherwise, it may download and execute it. This command takes our script at src/index.js as the entry point and generates dist/main.js as the output.
# Run the command for one package manager only.
# npm
npx webpack
# yarn
yarn webpack
# pnpm
pnpm exec webpack
[webpack-cli] Compilation finished
asset main.js 69.3 KiB [emitted] [minimized] (name: main) 1 related asset
runtime modules 1000 bytes 5 modules
cacheable modules 530 KiB
./src/index.js 257 bytes [built] [code generated]
./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
webpack 5.x.x compiled successfully in 1851 msOpen index.html from the dist directory in your browser and, if everything went right, you should see the following text: 'Hello webpack'.
Modules
The import and export statements have been standardized in ES2015. They are supported in most browsers at this moment, however there are some browsers that don't recognize the new syntax. But don't worry, webpack does support them out of the box.
Behind the scenes, webpack analyzes your module graph and bundles the modules into code that the browser can load in the right order. It handles module syntax such as import and export, and supports various other module syntaxes as well. See Module API for more information.
Note that webpack will not alter any code other than import and export statements. If you are using other ES2015 features, make sure to use a transpiler such as Babel via webpack's loader system.
Using a Configuration
As of version 4, webpack doesn't require any configuration, but most projects will need a more complex setup, which is why webpack supports a configuration file. This is much more efficient than having to manually type in a lot of commands in the terminal, so let's create one:
Webpack configuration files can be written using either CommonJS or ECMAScript modules. The examples below use modern ESM syntax.
project
webpack-demo
├── package.json
├── package-lock.json
+ ├── webpack.config.js
├── /dist
│ └── index.html
└── /src
└── index.jswebpack.config.js
import path from "node:path";
import { fileURLToPath } from "node:url";
// In Node.js versions prior to native support for import.meta.dirname,
// derive __dirname from import.meta.url.
// (Node 20.11+ supports import.meta.dirname and import.meta.filename.)
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: "./src/index.js",
output: {
filename: "main.js",
path: path.resolve(__dirname, "dist"),
},
};Now, let's run the build again but instead using our new configuration file:
# Run the command for one package manager only.
# npm
npx webpack --config webpack.config.js
# yarn
yarn webpack --config webpack.config.js
# pnpm
pnpm exec webpack --config webpack.config.js
[webpack-cli] Compilation finished
asset main.js 69.3 KiB [compared for emit] [minimized] (name: main) 1 related asset
runtime modules 1000 bytes 5 modules
cacheable modules 530 KiB
./src/index.js 257 bytes [built] [code generated]
./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
webpack 5.x.x compiled successfully in 1934 msA configuration file allows far more flexibility than CLI usage. We can specify loader rules, plugins, resolve options and many other enhancements this way. See the configuration documentation to learn more.
NPM Scripts
Given it's not particularly fun to run a local copy of webpack from the CLI, we can set up a little shortcut. Let's adjust our package.json by adding an npm script:
package.json
{
"name": "webpack-demo",
"version": "1.0.0",
"description": "",
"private": true,
"scripts": {
- "test": "echo \"Error: no test specified\" && exit 1"
+ "test": "echo \"Error: no test specified\" && exit 1",
+ "build": "webpack"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"webpack": "^5.105.0",
"webpack-cli": "^7.0.0"
},
"dependencies": {
"lodash": "^4.17.21"
}
}Now the npm run build command can be used in place of the npx command we used earlier. Note that within scripts we can reference locally installed npm packages by name the same way we did with npx. This convention is the standard in most npm-based projects because it allows all contributors to use the same set of common scripts.
Now run the following command and see if your script alias works:
# Run the command for one package manager only.
# npm
npm run build
# yarn
yarn build
# pnpm
pnpm run build
...
[webpack-cli] Compilation finished
asset main.js 69.3 KiB [compared for emit] [minimized] (name: main) 1 related asset
runtime modules 1000 bytes 5 modules
cacheable modules 530 KiB
./src/index.js 257 bytes [built] [code generated]
./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
webpack 5.x.x compiled successfully in 1940 msConclusion
Now that you have a basic build together, you should move on to the next guide Asset Management to learn how to manage assets like images and fonts with webpack. At this point, your project should look like this:
project
webpack-demo
├── package.json
├── package-lock.json
├── webpack.config.js
├── /dist
│ ├── main.js
│ └── index.html
├── /src
│ └── index.js
└── /node_modulesIf you want to learn more about webpack's design, you can check out the basic concepts and configuration pages. Furthermore, the API section digs into the various interfaces webpack offers.
Asset Management
If you've been following the guides from the start, you will now have a small project that shows "Hello webpack". Now let's try to incorporate some other assets, like images, to see how they can be handled.
Prior to webpack, front-end developers would use tools like grunt and gulp to process these assets and move them from their /src folder into their /dist or /build directory. The same idea was used for JavaScript modules, but tools like webpack will dynamically bundle all dependencies (creating what's known as a dependency graph). This is great because every module now explicitly states its dependencies and we'll avoid bundling modules that aren't in use.
One of the coolest webpack features is that you can also include any other type of file, besides JavaScript, for which there is a loader or built-in Asset Modules support. This means that the same benefits listed above for JavaScript (e.g. explicit dependencies) can be applied to everything used in building a website or web app. Let's start with CSS, as you may already be familiar with that setup.
Setup
Let's make a minor change to our project before we get started:
dist/index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
- <title>Getting Started</title>
+ <title>Asset Management</title>
</head>
<body>
- <script src="main.js"></script>
+ <script src="bundle.js"></script>
</body>
</html>webpack.config.js
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: './src/index.js',
output: {
- filename: 'main.js',
+ filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
},
};Loading CSS
Webpack understands CSS on its own, so you can import a CSS file from a JavaScript module with nothing to install and nothing to configure:
import "./style.css";Webpack parses the file — resolving its @import and url() references — and extracts it into a .css output file next to your bundle. CSS Modules, minification and content hashes all come from the same built-in support, which is still experimental; What's built-in states what it covers and what still needs a loader.
Preprocessors still use loaders, and module loaders can be chained. Each loader in the chain applies transformations to the processed resource. A chain is executed in reverse order (right to left).
For example, given the following rule:
export default {
module: {
rules: [
{
test: /\.scss$/i,
use: ["postcss-loader", "sass-loader"],
type: "css/auto",
},
],
},
};Even though postcss-loader appears before sass-loader in the use array, webpack runs sass-loader first (compiling Sass into CSS), then runs postcss-loader on the result. The type: 'css/auto' tells webpack to take the CSS that comes out of the chain through its own CSS pipeline.
If this order is not maintained, webpack may throw errors.
project
webpack-demo
├── package.json
├── package-lock.json
├── webpack.config.js
├── /dist
│ ├── bundle.js
│ └── index.html
├── /src
+ │ ├── style.css
│ └── index.js
└── /node_modulessrc/style.css
.hello {
color: red;
}src/index.js
import _ from 'lodash';
+import './style.css';
function component() {
const element = document.createElement('div');
// Lodash, now imported by this script
element.innerHTML = _.join(['Hello', 'webpack'], ' ');
+ element.classList.add('hello');
return element;
}
document.body.appendChild(component());Now run your build command:
$ npm run build
...
[webpack-cli] Compilation finished
asset bundle.js 69.6 KiB [emitted] [minimized] (name: main) 1 related asset
asset bundle.css 17 bytes [emitted] [minimized] (name: main)
runtime modules 1020 bytes 6 modules
cacheable modules 533 KiB (javascript) 23 bytes (css)
./src/index.js + 1 modules 313 bytes [built] [code generated]
./node_modules/lodash/lodash.js 533 KiB [built] [code generated]
css ./src/style.css 23 bytes [built] [code generated]
webpack 5.x.x compiled successfully in 1689 msWebpack extracted the CSS into its own file, bundle.css — the name follows output.cssFilename, which defaults from output.filename. Link it from the page:
dist/index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Asset Management</title>
+ <link rel="stylesheet" href="bundle.css" />
</head>
<body>
<script src="bundle.js"></script>
</body>
</html>Open up dist/index.html in your browser again and you should see that Hello webpack is now styled in red.
Minification is built in too: in mode: 'production' webpack minifies the emitted CSS with no extra plugin, and can maintain vendor prefixes for your browserslist target — see Minification. On top of that, loaders exist for pretty much any flavor of CSS you can think of – postcss, sass, and less to name a few.
Loading Images
So now we're pulling in our CSS, but what about our images like backgrounds and icons? As of webpack 5, using the built-in Asset Modules we can easily incorporate those in our system as well:
webpack.config.js
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
},
+ module: {
+ rules: [
+ {
+ test: /\.(png|svg|jpg|jpeg|gif)$/i,
+ type: 'asset/resource',
+ },
+ ],
+ },
};Now, when you import MyImage from './my-image.png', that image will be processed and added to your output directory and the MyImage variable will contain the final url of that image after processing. The same happens for a url('./my-image.png') inside your CSS: webpack recognizes it as a local file and rewrites the path to the final one in your output directory. With experiments.html enabled, <img src="./my-image.png" /> in an HTML file is handled the same way — see Native HTML.
Let's add an image to our project and see how this works, you can use any image you like:
project
webpack-demo
├── package.json
├── package-lock.json
├── webpack.config.js
├── /dist
│ ├── bundle.js
│ └── index.html
├── /src
+ │ ├── icon.png
│ ├── style.css
│ └── index.js
└── /node_modulessrc/index.js
import _ from 'lodash';
import './style.css';
+import Icon from './icon.png';
function component() {
const element = document.createElement('div');
// Lodash, now imported by this script
element.innerHTML = _.join(['Hello', 'webpack'], ' ');
element.classList.add('hello');
+ // Add the image to our existing div.
+ const myIcon = new Image();
+ myIcon.src = Icon;
+
+ element.appendChild(myIcon);
+
return element;
}
document.body.appendChild(component());src/style.css
.hello {
color: red;
+ background: url('./icon.png');
}Let's create a new build and open up the index.html file again:
$ npm run build
...
[webpack-cli] Compilation finished
asset bundle.js 70.1 KiB [emitted] [minimized] (name: main) 1 related asset
asset 86c447381066a936d5c5.png 7.67 KiB [emitted] [immutable] [from: src/icon.png] (auxiliary name: main)
asset bundle.css 58 bytes [emitted] [minimized] (name: main)
runtime modules 1.95 KiB 7 modules
cacheable modules 534 KiB (javascript) 58 bytes (css) 7.67 KiB (asset) 42 bytes (asset-url)
javascript modules 534 KiB
./src/index.js + 1 modules 511 bytes [built] [code generated]
./node_modules/lodash/lodash.js 533 KiB [built] [code generated]
css ./src/style.css 58 bytes [built] [code generated]
./src/icon.png 7.67 KiB (asset) 42 bytes (javascript) 42 bytes (asset-url) [built] [code generated]
webpack 5.x.x compiled successfully in 1684 msIf all went well, you should now see your icon as a repeating background, as well as an img element beside our Hello webpack text. If you inspect this element, you'll see that the actual filename has changed to something like 86c447381066a936d5c5.png. This means webpack found our file in the src folder and processed it!
Loading Fonts
So what about other assets like fonts? The Asset Modules will take any file you load through them and output it to your build directory. This means we can use them for any kind of file, including fonts. Let's update our webpack.config.js to handle font files:
webpack.config.js
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
},
module: {
rules: [
{
test: /\.(png|svg|jpg|jpeg|gif)$/i,
type: 'asset/resource',
},
+ {
+ test: /\.(woff|woff2|eot|ttf|otf)$/i,
+ type: 'asset/resource',
+ },
],
},
};Add some font files to your project:
project
webpack-demo
├── package.json
├── package-lock.json
├── webpack.config.js
├── /dist
│ ├── bundle.js
│ └── index.html
├── /src
+ │ ├── my-font.woff
+ │ ├── my-font.woff2
│ ├── icon.png
│ ├── style.css
│ └── index.js
└── /node_modulesWith the rule configured and fonts in place, you can incorporate them via an @font-face declaration. The local url(...) directive will be picked up by webpack, as it was with the image:
src/style.css
+@font-face {
+ font-family: 'MyFont';
+ src: url('./my-font.woff2') format('woff2'),
+ url('./my-font.woff') format('woff');
+ font-weight: 600;
+ font-style: normal;
+}
+
.hello {
color: red;
+ font-family: 'MyFont';
background: url('./icon.png');
}Now run a new build and let's see if webpack handled our fonts:
$ npm run build
...
[webpack-cli] Compilation finished
assets by status 7.67 KiB [cached] 1 asset
assets by status 33.5 KiB [emitted]
asset f32e23c95fbf20947766.woff 18.8 KiB [emitted] [immutable] [from: src/my-font.woff] (auxiliary name: main)
asset f8668ded30a04fd1aed7.woff2 14.5 KiB [emitted] [immutable] [from: src/my-font.woff2] (auxiliary name: main)
asset bundle.css 237 bytes [emitted] [minimized] (name: main)
asset bundle.js 70.1 KiB [compared for emit] [minimized] (name: main) 1 related asset
runtime modules 1.95 KiB 7 modules
cacheable modules 534 KiB (javascript) 41 KiB (asset) 126 bytes (asset-url) 255 bytes (css)
modules by path ./src/ 553 bytes (javascript) 41 KiB (asset) 126 bytes (asset-url)
./src/index.js + 1 modules 511 bytes [built] [code generated]
./src/icon.png 7.67 KiB (asset) 42 bytes (javascript) 42 bytes (asset-url) [built] [code generated]
./src/my-font.woff2 14.5 KiB (asset) 42 bytes (asset-url) [built] [code generated]
./src/my-font.woff 18.8 KiB (asset) 42 bytes (asset-url) [built] [code generated]
./node_modules/lodash/lodash.js 533 KiB [built] [code generated]
css ./src/style.css 255 bytes [built] [code generated]
webpack 5.x.x compiled successfully in 1644 msOpen up dist/index.html again and see if our Hello webpack text has changed to the new font. If all is well, you should see the changes.
Loading Data
Another useful asset that can be loaded is data, like JSON files, CSVs, TSVs, and XML. Support for JSON is actually built-in, similar to NodeJS, meaning import Data from './data.json' will work by default. To import CSVs, TSVs, and XML you could use the csv-loader and xml-loader. Let's handle loading all three:
npm install --save-dev csv-loader xml-loaderwebpack.config.js
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
},
module: {
rules: [
{
test: /\.(png|svg|jpg|jpeg|gif)$/i,
type: 'asset/resource',
},
{
test: /\.(woff|woff2|eot|ttf|otf)$/i,
type: 'asset/resource',
},
+ {
+ test: /\.(csv|tsv)$/i,
+ use: ['csv-loader'],
+ },
+ {
+ test: /\.xml$/i,
+ use: ['xml-loader'],
+ },
],
},
};Add some data files to your project:
project
webpack-demo
├── package.json
├── package-lock.json
├── webpack.config.js
├── /dist
│ ├── bundle.js
│ └── index.html
├── /src
+ │ ├── data.xml
+ │ ├── data.csv
│ ├── my-font.woff
│ ├── my-font.woff2
│ ├── icon.png
│ ├── style.css
│ └── index.js
└── /node_modulessrc/data.xml
<?xml version="1.0" encoding="UTF-8"?>
<note>
<to>Mary</to>
<from>John</from>
<heading>Reminder</heading>
<body>Call Cindy on Tuesday</body>
</note>src/data.csv
to,from,heading,body
Mary,John,Reminder,Call Cindy on Tuesday
Zoe,Bill,Reminder,Buy orange juice
Autumn,Lindsey,Letter,I miss you
Now you can import any one of those four types of data (JSON, CSV, TSV, XML) and the Data variable you import, will contain parsed JSON for consumption:
src/index.js
import _ from 'lodash';
import './style.css';
import Icon from './icon.png';
+import Data from './data.xml';
+import Notes from './data.csv';
function component() {
const element = document.createElement('div');
// Lodash, now imported by this script
element.innerHTML = _.join(['Hello', 'webpack'], ' ');
element.classList.add('hello');
// Add the image to our existing div.
const myIcon = new Image();
myIcon.src = Icon;
element.appendChild(myIcon);
+ console.log(Data);
+ console.log(Notes);
+
return element;
}
document.body.appendChild(component());Re-run the npm run build command and open dist/index.html. If you look at the console in your developer tools, you should be able to see your imported data being logged to the console!
// No warning
import data from "./data.json";// Warning shown, this is not allowed by the spec.
import { foo } from "./data.json";Customize parser of JSON modules
It's possible to import any toml, yaml or json5 files as a JSON module by using a custom parser instead of a specific webpack loader.
Let's say you have a data.toml, a data.yaml and a data.json5 files under src folder:
src/data.toml
title = "TOML Example"
[owner]
name = "Tom Preston-Werner"
organization = "GitHub"
bio = "GitHub Cofounder & CEO\nLikes tater tots and beer."
dob = 1979-05-27T07:32:00Z
src/data.yaml
title: YAML Example
owner:
name: Tom Preston-Werner
organization: GitHub
bio: |-
GitHub Cofounder & CEO
Likes tater tots and beer.
dob: 1979-05-27T07:32:00.000Zsrc/data.json5
{
// comment
title: "JSON5 Example",
owner: {
name: "Tom Preston-Werner",
organization: "GitHub",
bio: "GitHub Cofounder & CEO\n\
Likes tater tots and beer.",
dob: "1979-05-27T07:32:00.000Z",
},
}
Install toml, yamljs and json5 packages first:
npm install toml yamljs json5 --save-devAnd configure them in your webpack configuration:
webpack.config.js
import path from 'node:path';
import { fileURLToPath } from 'node:url';
+import toml from 'toml';
+import yaml from 'yamljs';
+import json5 from 'json5';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
},
module: {
rules: [
{
test: /\.(png|svg|jpg|jpeg|gif)$/i,
type: 'asset/resource',
},
{
test: /\.(woff|woff2|eot|ttf|otf)$/i,
type: 'asset/resource',
},
{
test: /\.(csv|tsv)$/i,
use: ['csv-loader'],
},
{
test: /\.xml$/i,
use: ['xml-loader'],
},
+ {
+ test: /\.toml$/i,
+ type: 'json',
+ parser: {
+ parse: toml.parse,
+ },
+ },
+ {
+ test: /\.yaml$/i,
+ type: 'json',
+ parser: {
+ parse: yaml.parse,
+ },
+ },
+ {
+ test: /\.json5$/i,
+ type: 'json',
+ parser: {
+ parse: json5.parse,
+ },
+ },
],
},
};src/index.js
import _ from 'lodash';
import './style.css';
import Icon from './icon.png';
import Data from './data.xml';
import Notes from './data.csv';
+import toml from './data.toml';
+import yaml from './data.yaml';
+import json from './data.json5';
+
+console.log(toml.title); // output `TOML Example`
+console.log(toml.owner.name); // output `Tom Preston-Werner`
+
+console.log(yaml.title); // output `YAML Example`
+console.log(yaml.owner.name); // output `Tom Preston-Werner`
+
+console.log(json.title); // output `JSON5 Example`
+console.log(json.owner.name); // output `Tom Preston-Werner`
function component() {
const element = document.createElement('div');
// Lodash, now imported by this script
element.innerHTML = _.join(['Hello', 'webpack'], ' ');
element.classList.add('hello');
// Add the image to our existing div.
const myIcon = new Image();
myIcon.src = Icon;
element.appendChild(myIcon);
console.log(Data);
console.log(Notes);
return element;
}
document.body.appendChild(component());Re-run the npm run build command and open dist/index.html. You should be able to see your imported data being logged to the console!
Global Assets
The coolest part of everything mentioned above, is that loading assets this way allows you to group modules and assets in a more intuitive way. Instead of relying on a global /assets directory that contains everything, you can group assets with the code that uses them. For example, a structure like this can be useful:
- ├── /assets
+ └── /components
+ └── /my-component
+ ├── index.jsx
+ ├── index.css
+ ├── icon.svg
+ └── img.pngThis setup makes your code a lot more portable as everything that is closely coupled now lives together. Let's say you want to use /my-component in another project, copy or move it into the /components directory over there. As long as you've installed any external dependencies and your configuration has the same loaders defined, you should be good to go.
However, let's say you're locked into your old ways or you have some assets that are shared between multiple components (views, templates, modules, etc.). It's still possible to store these assets in a base directory and even use aliasing to make them easier to import.
Wrapping up
For the next guides we won't be using all the different assets we've used in this guide, so let's do some cleanup so we're prepared for the next piece of the guides Output Management:
project
webpack-demo
├── package.json
├── package-lock.json
├── webpack.config.js
├── /dist
│ ├── bundle.js
│ └── index.html
├── /src
- │ ├── data.csv
- │ ├── data.json5
- │ ├── data.toml
- │ ├── data.xml
- │ ├── data.yaml
- │ ├── icon.png
- │ ├── my-font.woff
- │ ├── my-font.woff2
- │ ├── style.css
│ └── index.js
└── /node_moduleswebpack.config.js
import path from 'node:path';
import { fileURLToPath } from 'node:url';
-import toml from 'toml';
-import yaml from 'yamljs';
-import json5 from 'json5';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
},
- module: {
- rules: [
- {
- test: /\.(png|svg|jpg|jpeg|gif)$/i,
- type: 'asset/resource',
- },
- {
- test: /\.(woff|woff2|eot|ttf|otf)$/i,
- type: 'asset/resource',
- },
- {
- test: /\.(csv|tsv)$/i,
- use: ['csv-loader'],
- },
- {
- test: /\.xml$/i,
- use: ['xml-loader'],
- },
- {
- test: /\.toml$/i,
- type: 'json',
- parser: {
- parse: toml.parse,
- },
- },
- {
- test: /\.yaml$/i,
- type: 'json',
- parser: {
- parse: yaml.parse,
- },
- },
- {
- test: /\.json5$/i,
- type: 'json',
- parser: {
- parse: json5.parse,
- },
- },
- ],
- },
};src/index.js
import _ from 'lodash';
-import './style.css';
-import Icon from './icon.png';
-import Data from './data.xml';
-import Notes from './data.csv';
-import toml from './data.toml';
-import yaml from './data.yaml';
-import json from './data.json5';
-
-console.log(toml.title); // output `TOML Example`
-console.log(toml.owner.name); // output `Tom Preston-Werner`
-
-console.log(yaml.title); // output `YAML Example`
-console.log(yaml.owner.name); // output `Tom Preston-Werner`
-
-console.log(json.title); // output `JSON5 Example`
-console.log(json.owner.name); // output `Tom Preston-Werner`
function component() {
const element = document.createElement('div');
- // Lodash, now imported by this script
element.innerHTML = _.join(['Hello', 'webpack'], ' ');
- element.classList.add('hello');
-
- // Add the image to our existing div.
- const myIcon = new Image();
- myIcon.src = Icon;
-
- element.appendChild(myIcon);
-
- console.log(Data);
- console.log(Notes);
return element;
}
document.body.appendChild(component());And remove those dependencies we added before:
npm uninstall csv-loader json5 toml xml-loader yamljsNext guide
Let's move on to Output Management
Further Reading
- Loading Fonts on SurviveJS
Output Management
So far we've manually included all our assets in our index.html file, but as your application grows and once you start using hashes in filenames and outputting multiple bundles, it will be difficult to keep managing your index.html file manually. However, webpack can take the page over for you.
Preparation
First, let's adjust our project a little bit:
project
webpack-demo
├── package.json
├── package-lock.json
├── webpack.config.js
├── /dist
├── /src
│ ├── index.js
+ │ ├── print.js
└── /node_modulesLet's add some logic to our src/print.js file:
src/print.js
export default function printMe() {
console.log("I get called from print.js!");
}And use that function in our src/index.js file:
src/index.js
import _ from 'lodash';
+import printMe from './print.js';
function component() {
const element = document.createElement('div');
+ const btn = document.createElement('button');
element.innerHTML = _.join(['Hello', 'webpack'], ' ');
+ btn.innerHTML = 'Click me and check the console!';
+ btn.onclick = printMe;
+
+ element.appendChild(btn);
+
return element;
}
document.body.appendChild(component());Let's also update our dist/index.html file, in preparation for webpack to split out entries:
dist/index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
- <title>Asset Management</title>
+ <title>Output Management</title>
+ <script src="./print.bundle.js"></script>
</head>
<body>
- <script src="bundle.js"></script>
+ <script src="./index.bundle.js"></script>
</body>
</html>Now adjust the config. We'll be adding our src/print.js as a new entry point (print) and we'll change the output as well, so that it will dynamically generate bundle names, based on the entry point names:
webpack.config.js
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
- entry: './src/index.js',
+ entry: {
+ index: './src/index.js',
+ print: './src/print.js',
+ },
output: {
- filename: 'bundle.js',
+ filename: '[name].bundle.js',
path: path.resolve(__dirname, 'dist'),
},
};Let's run npm run build and see what this generates:
...
[webpack-cli] Compilation finished
asset index.bundle.js 69.5 KiB [emitted] [minimized] (name: index) 1 related asset
asset print.bundle.js 316 bytes [emitted] [minimized] (name: print)
runtime modules 1.36 KiB 7 modules
cacheable modules 530 KiB
./src/index.js 406 bytes [built] [code generated]
./src/print.js 83 bytes [built] [code generated]
./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
webpack 5.x.x compiled successfully in 1996 msWe can see that webpack generates our print.bundle.js and index.bundle.js files, which we also specified in our index.html file. if you open index.html in your browser, you can see what happens when you click the button.
But what would happen if we changed the name of one of our entry points, or even added a new one? The generated bundles would be renamed on a build, but our index.html file would still reference the old names. Let's fix that by handing the page to webpack.
Making the page an entry point
Webpack understands HTML natively behind experiments.html: point entry at an .html file and the page joins the build. Every <script src> it references becomes part of the module graph, and the emitted page has those URLs rewritten to the generated filenames — so the page and the bundles cannot drift apart.
Move index.html out of dist/ and into src/, next to the files it refers to:
project
webpack-demo
├── package.json
├── package-lock.json
├── webpack.config.js
├── /dist
├── /src
+ │ ├── index.html
│ ├── index.js
│ ├── print.js
└── /node_modulessrc/index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Output Management</title>
- <script src="./print.bundle.js"></script>
+ <script src="./print.js"></script>
</head>
<body>
- <script src="./index.bundle.js"></script>
+ <script src="./index.js"></script>
</body>
</html>The tags point at the files you actually wrote, not at build output. Now adjust the config to build the page rather than the two scripts:
webpack.config.js
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
- entry: {
- index: './src/index.js',
- print: './src/print.js',
- },
+ entry: './src/index.html',
+ experiments: {
+ html: true,
+ },
output: {
filename: '[name].bundle.js',
+ htmlFilename: '[name].html',
path: path.resolve(__dirname, 'dist'),
},
};There is no list of entry points to keep in sync any more — the page states which scripts it needs, and output.htmlFilename says where the built page goes. Let's see what npm run build generates now:
...
[webpack-cli] Compilation finished
asset main1.bundle.js 69.1 KiB [emitted] [minimized] 1 related asset
asset main.bundle.js 1.39 KiB [emitted] [minimized]
asset index.html 183 bytes [emitted] [minimized] (auxiliary name: main)
runtime modules 3.13 KiB 6 modules
cacheable modules 534 KiB (javascript) 213 bytes (html)
modules by path ./src/*.js 487 bytes
./src/index.js 403 bytes [built] [code generated]
./src/print.js 84 bytes [built] [code generated]
./src/index.html 213 bytes [built] [code generated]
./node_modules/lodash/lodash.js 533 KiB [built] [code generated]
webpack 5.x.x compiled successfully in 3064 msWebpack has written dist/index.html for you, with both <script> tags rewritten to the bundles it just emitted. Rename an entry, add a script tag, or turn on hashed filenames, and the page follows along on the next build.
Cleaning up the /dist folder
As you might have noticed over the past guides and code example, our /dist folder has become quite cluttered. Webpack will generate the files and put them in the /dist folder for you, but it doesn't keep track of which files are actually in use by your project.
In general it's good practice to clean the /dist folder before each build, so that only used files will be generated. Let's take care of that with output.clean option.
webpack.config.js
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: './src/index.html',
experiments: {
html: true,
},
output: {
filename: '[name].bundle.js',
htmlFilename: '[name].html',
path: path.resolve(__dirname, 'dist'),
+ clean: true,
},
};Now run an npm run build and inspect the /dist folder. If everything went well you should now only see the files generated from the build and no more old files!
The Manifest
You might be wondering how webpack and its plugins seem to "know" what files are being generated. The answer is in the manifest that webpack keeps to track how all the modules map to the output bundles. If you're interested in managing webpack's output in other ways, the manifest would be a good place to start.
The manifest data can be extracted into a json file for consumption using the ManifestPlugin.
We won't go through a full example of how to use this plugin within your projects, but you can read up on the concept page and the caching guide to find out how this ties into long term caching.
Conclusion
Now that you've learned about dynamically adding bundles to your HTML, let's dive into the development guide. Or, if you want to dig into more advanced topics, we would recommend heading over to the code splitting guide.
Development
If you've been following the guides, you should have a solid understanding of some of the webpack basics. Before we continue, let's look into setting up a development environment to make our lives a little easier.
Let's start by setting mode to 'development', and let's retitle the page while we are at it.
src/index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
- <title>Output Management</title>
+ <title>Development</title>
<script src="./print.js"></script>
</head>
<body>
<script src="./index.js"></script>
</body>
</html>webpack.config.js
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
+ mode: 'development',
entry: './src/index.html',
experiments: {
html: true,
},
output: {
filename: '[name].bundle.js',
htmlFilename: '[name].html',
path: path.resolve(__dirname, 'dist'),
clean: true,
},
};Using source maps
When webpack bundles your source code, it can become difficult to track down errors and warnings to their original location. For example, if you bundle three source files (a.js, b.js, and c.js) into one bundle (bundle.js) and one of the source files contains an error, the stack trace will point to bundle.js. This isn't always helpful as you probably want to know exactly which source file the error came from.
In order to make it easier to track down errors and warnings, JavaScript offers source maps, which map your compiled code back to your original source code. If an error originates from b.js, the source map will tell you exactly that.
There are a lot of different options available when it comes to source maps. Be sure to check them out so you can configure them to your needs.
For this guide, let's use the inline-source-map option, which is good for illustrative purposes (though not for production):
webpack.config.js
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
mode: 'development',
entry: './src/index.html',
experiments: {
html: true,
},
+ devtool: 'inline-source-map',
output: {
filename: '[name].bundle.js',
htmlFilename: '[name].html',
path: path.resolve(__dirname, 'dist'),
clean: true,
},
};Now let's make sure we have something to debug, so let's create an error in our print.js file:
src/print.js
export default function printMe() {
- console.log('I get called from print.js!');
+ cosnole.log('I get called from print.js!');
}Run an npm run build, it should compile to something like this:
...
[webpack-cli] Compilation finished
asset main1.bundle.js 1.38 MiB [emitted]
asset main.bundle.js 16.2 KiB [emitted]
asset index.html 216 bytes [emitted] (auxiliary name: main)
runtime modules 3.35 KiB 7 modules
cacheable modules 534 KiB (javascript) 207 bytes (html)
modules by path ./src/*.js 487 bytes
./src/index.js 403 bytes [built] [code generated]
./src/print.js 84 bytes [built] [code generated]
./src/index.html 207 bytes [built] [code generated]
./node_modules/lodash/lodash.js 533 KiB [built] [code generated]
webpack 5.x.x compiled successfully in 322 msNow open the resulting index.html file in your browser. Click the button and look in your console where the error is displayed. The error should say something like this:
Uncaught ReferenceError: cosnole is not defined
at HTMLButtonElement.printMe (print.js:2)We can see that the error also contains a reference to the file (print.js) and line number (2) where the error occurred. This is great because now we know exactly where to look in order to fix the issue.
Choosing a Development Tool
It quickly becomes a hassle to manually run npm run build every time you want to compile your code.
There are a couple of different options available in webpack that help you automatically compile your code whenever it changes:
In most cases, you probably would want to use webpack-dev-server, but let's explore all of the above options.
Using Watch Mode
You can instruct webpack to "watch" all files within your dependency graph for changes. If one of these files is updated, the code will be recompiled so you don't have to run the full build manually.
Let's add an npm script that will start webpack's Watch Mode:
package.json
{
"name": "webpack-demo",
"version": "1.0.0",
"description": "",
"private": true,
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
+ "watch": "webpack --watch",
"build": "webpack"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"webpack": "^5.105.0",
"webpack-cli": "^7.0.0"
},
"dependencies": {
"lodash": "^4.17.21"
}
}Now run npm run watch from the command line and see how webpack compiles your code.
You can see that it doesn't exit the command line because the script is currently watching your files.
Now, while webpack is watching your files, let's remove the error we introduced earlier:
src/print.js
export default function printMe() {
- cosnole.log('I get called from print.js!');
+ console.log('I get called from print.js!');
}Now save your file and check the terminal window. You should see that webpack automatically recompiles the changed module!
The only downside is that you have to refresh your browser in order to see the changes. It would be much nicer if that would happen automatically as well, so let's try webpack-dev-server which will do exactly that.
Using webpack-dev-server
The webpack-dev-server provides you with a rudimentary web server and the ability to use live reloading. Let's set it up:
npm install --save-dev webpack-dev-serverChange your configuration file to tell the dev server where to look for files:
webpack.config.js
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
mode: 'development',
entry: './src/index.html',
experiments: {
html: true,
},
devtool: 'inline-source-map',
+ devServer: {
+ static: './dist',
+ },
output: {
filename: '[name].bundle.js',
htmlFilename: '[name].html',
path: path.resolve(__dirname, 'dist'),
clean: true,
},
+ optimization: {
+ runtimeChunk: 'single',
+ },
};This tells webpack-dev-server to serve the files from the dist directory on localhost:8080.
Let's add a script to easily run the dev server as well:
package.json
{
"name": "webpack-demo",
"version": "1.0.0",
"description": "",
"private": true,
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"watch": "webpack --watch",
+ "start": "webpack serve --open",
"build": "webpack"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"webpack": "^5.105.0",
"webpack-cli": "^7.0.0",
"webpack-dev-server": "^5.2.3"
},
"dependencies": {
"lodash": "^4.17.21"
}
}Now we can run npm start from the command line and we will see our browser automatically loading up our page. If you now change any of the source files and save them, the web server will automatically reload after the code has been compiled. Give it a try!
The webpack-dev-server comes with many configurable options. Head over to the documentation to learn more.
Using webpack-dev-middleware
webpack-dev-middleware is a wrapper that will emit files processed by webpack to a server. This is used in webpack-dev-server internally, however it's available as a separate package to allow more custom setups if desired. We'll take a look at an example that combines webpack-dev-middleware with an express server.
Let's install express and webpack-dev-middleware so we can get started:
npm install --save-dev express webpack-dev-middlewareNow we need to make some adjustments to our webpack configuration file in order to make sure the middleware will function correctly:
webpack.config.js
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
mode: 'development',
entry: './src/index.html',
experiments: {
html: true,
},
devtool: 'inline-source-map',
devServer: {
static: './dist',
},
output: {
filename: '[name].bundle.js',
htmlFilename: '[name].html',
path: path.resolve(__dirname, 'dist'),
clean: true,
+ publicPath: '/',
},
};The publicPath will be used within our server script as well in order to make sure files are served correctly on http://localhost:3000. We'll specify the port number later. The next step is setting up our custom express server:
project
webpack-demo
├── package.json
├── package-lock.json
├── webpack.config.js
+ ├── server.js
├── /dist
├── /src
│ ├── index.html
│ ├── index.js
│ └── print.js
└── /node_modulesserver.js
import express from "express";
import webpack from "webpack";
import webpackDevMiddleware from "webpack-dev-middleware";
import config from "./webpack.config.js";
const app = express();
const compiler = webpack(config);
// Tell express to use the webpack-dev-middleware and use the webpack.config.js
// configuration file as a base.
app.use(
webpackDevMiddleware(compiler, {
publicPath: config.output.publicPath,
}),
);
// Serve the files on port 3000.
app.listen(3000, () => {
console.log("Example app listening on port 3000!\n");
});Now add an npm script to make it a little easier to run the server:
package.json
{
"name": "webpack-demo",
"version": "1.0.0",
"description": "",
"private": true,
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"watch": "webpack --watch",
"start": "webpack serve --open",
+ "server": "node server.js",
"build": "webpack"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"express": "^5.2.1",
"webpack": "^5.105.0",
"webpack-cli": "^7.0.0",
"webpack-dev-middleware": "^8.0.3",
"webpack-dev-server": "^5.2.3"
},
"dependencies": {
"lodash": "^4.17.21"
}
}Now in your terminal run npm run server, it should give you an output similar to this:
Example app listening on port 3000!
...
<i> [webpack-dev-middleware] asset main1.bundle.js 1.38 MiB [emitted]
<i> asset main.bundle.js 16.2 KiB [emitted]
<i> asset index.html 216 bytes [emitted] (auxiliary name: main)
<i> runtime modules 3.35 KiB 7 modules
<i> cacheable modules 534 KiB (javascript) 207 bytes (html)
<i> ./src/index.js 403 bytes [built] [code generated]
<i> ./src/print.js 84 bytes [built] [code generated]
<i> ./src/index.html 207 bytes [built] [code generated]
<i> ./node_modules/lodash/lodash.js 533 KiB [built] [code generated]
<i> webpack 5.x.x compiled successfully in 322 ms
<i> [webpack-dev-middleware] Compiled successfully.
<i> [webpack-dev-middleware] Compiling...
<i> [webpack-dev-middleware] assets by status 1.38 MiB [cached] 2 assets
<i> cached modules 530 KiB (javascript) 1.9 KiB (runtime) [cached] 12 modules
<i> webpack 5.x.x compiled successfully in 19 ms
<i> [webpack-dev-middleware] Compiled successfully.Now fire up your browser and go to http://localhost:3000. You should see your webpack app running and functioning!
Adjusting Your Text Editor
When using automatic compilation of your code, you could run into issues when saving your files. Some editors have a "safe write" feature that can potentially interfere with recompilation.
To disable this feature in some common editors, see the list below:
- Sublime Text 3: Add
atomic_save: 'false'to your user preferences. - JetBrains IDEs (e.g. WebStorm): Uncheck "Use safe write" in
Preferences > Appearance & Behavior > System Settings. - Vim: Add
:set backupcopy=yesto your settings.
Conclusion
Now that you've learned how to automatically compile your code and run a development server, you can check out the next guide, which will cover Code Splitting.
Code Splitting
Code splitting is one of the most compelling features of webpack. This feature allows you to split your code into various bundles which can then be loaded on demand or in parallel. It can be used to achieve smaller bundles and control resource load prioritization which, if used correctly, can have a major impact on load time.
There are three general approaches to code splitting available:
- Entry Points: Manually split code using
entryconfiguration. - Prevent Duplication: Use Entry dependencies or
SplitChunksPluginto dedupe and split chunks. - Dynamic Imports: Split code via inline function calls within modules.
Entry Points
This is by far the easiest and most intuitive way to split code. However, it is more manual and has some pitfalls we will go over. Let's take a look at how we might split another module from the main bundle:
project
webpack-demo
├── package.json
├── package-lock.json
├── webpack.config.js
├── /dist
├── /src
│ ├── index.js
+ │ └── another-module.js
└── /node_modulesanother-module.js
import _ from "lodash";
console.log(_.join(["Another", "module", "loaded!"], " "));webpack.config.js
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
- entry: './src/index.js',
+ mode: 'development',
+ entry: {
+ index: './src/index.js',
+ another: './src/another-module.js',
+ },
output: {
- filename: 'main.js',
+ filename: '[name].bundle.js',
path: path.resolve(__dirname, 'dist'),
},
};This will yield the following build result:
...
[webpack-cli] Compilation finished
asset index.bundle.js 553 KiB [emitted] (name: index)
asset another.bundle.js 553 KiB [emitted] (name: another)
runtime modules 2.49 KiB 12 modules
cacheable modules 530 KiB
./src/index.js 257 bytes [built] [code generated]
./src/another-module.js 84 bytes [built] [code generated]
./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
webpack 5.x.x compiled successfully in 245 msAs mentioned there are some pitfalls to this approach:
- If there are any duplicated modules between entry chunks they will be included in both bundles.
- It isn't as flexible and can't be used to dynamically split code with the core application logic.
The first of these two points is definitely an issue for our example, as lodash is also imported within ./src/index.js and will thus be duplicated in both bundles. Let's remove this duplication in next section.
Prevent Duplication
Entry dependencies
The dependOn option allows to share the modules between the chunks:
webpack.config.js
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
mode: 'development',
entry: {
- index: './src/index.js',
- another: './src/another-module.js',
+ index: {
+ import: './src/index.js',
+ dependOn: 'shared',
+ },
+ another: {
+ import: './src/another-module.js',
+ dependOn: 'shared',
+ },
+ shared: 'lodash',
},
output: {
filename: '[name].bundle.js',
path: path.resolve(__dirname, 'dist'),
},
};If we're going to use multiple entry points on a single HTML page, optimization.runtimeChunk: 'single' is needed too, otherwise we could get into trouble described here.
webpack.config.js
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
mode: 'development',
entry: {
index: {
import: './src/index.js',
dependOn: 'shared',
},
another: {
import: './src/another-module.js',
dependOn: 'shared',
},
shared: 'lodash',
},
output: {
filename: '[name].bundle.js',
path: path.resolve(__dirname, 'dist'),
},
+ optimization: {
+ runtimeChunk: 'single',
+ },
};And here's the result of build:
...
[webpack-cli] Compilation finished
asset shared.bundle.js 549 KiB [compared for emit] (name: shared)
asset runtime.bundle.js 7.79 KiB [compared for emit] (name: runtime)
asset index.bundle.js 1.77 KiB [compared for emit] (name: index)
asset another.bundle.js 1.65 KiB [compared for emit] (name: another)
Entrypoint index 1.77 KiB = index.bundle.js
Entrypoint another 1.65 KiB = another.bundle.js
Entrypoint shared 557 KiB = runtime.bundle.js 7.79 KiB shared.bundle.js 549 KiB
runtime modules 3.76 KiB 7 modules
cacheable modules 530 KiB
./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
./src/another-module.js 84 bytes [built] [code generated]
./src/index.js 257 bytes [built] [code generated]
webpack 5.x.x compiled successfully in 249 msAs you can see there's another runtime.bundle.js file generated besides shared.bundle.js, index.bundle.js and another.bundle.js.
Although using multiple entry points per page is allowed in webpack, it should be avoided when possible in favor of an entry point with multiple imports: entry: { page: ['./analytics', './app'] }. This results in a better optimization and consistent execution order when using async script tags.
SplitChunksPlugin
The SplitChunksPlugin allows us to extract common dependencies into an existing entry chunk or an entirely new chunk. Let's use this to de-duplicate the lodash dependency from the previous example:
webpack.config.js
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
mode: 'development',
entry: {
index: './src/index.js',
another: './src/another-module.js',
},
output: {
filename: '[name].bundle.js',
path: path.resolve(__dirname, 'dist'),
},
+ optimization: {
+ splitChunks: {
+ chunks: 'all',
+ },
+ },
};With the optimization.splitChunks configuration option in place, we should now see the duplicate dependency removed from our index.bundle.js and another.bundle.js. The plugin should notice that we've separated lodash out to a separate chunk and remove the dead weight from our main bundle. However, it's important to note that common dependencies are only extracted into a separate chunk if they meet the size thresholds specified by webpack.
Let's do an npm run build to see if it worked:
...
[webpack-cli] Compilation finished
asset vendors-node_modules_lodash_lodash_js.bundle.js 549 KiB [compared for emit] (id hint: vendors)
asset index.bundle.js 8.92 KiB [compared for emit] (name: index)
asset another.bundle.js 8.8 KiB [compared for emit] (name: another)
Entrypoint index 558 KiB = vendors-node_modules_lodash_lodash_js.bundle.js 549 KiB index.bundle.js 8.92 KiB
Entrypoint another 558 KiB = vendors-node_modules_lodash_lodash_js.bundle.js 549 KiB another.bundle.js 8.8 KiB
runtime modules 7.64 KiB 14 modules
cacheable modules 530 KiB
./src/index.js 257 bytes [built] [code generated]
./src/another-module.js 84 bytes [built] [code generated]
./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
webpack 5.x.x compiled successfully in 241 msCSS is split the same way: webpack extracts a stylesheet per chunk on its own, so a lazily imported CSS file arrives with the chunk that needs it — see Native CSS.
Dynamic Imports
Two similar techniques are supported by webpack when it comes to dynamic code splitting. The first and recommended approach is to use the import() syntax that conforms to the ECMAScript proposal for dynamic imports. The legacy, webpack-specific approach is to use require.ensure. Let's try using the first of these two approaches...
Before we start, let's remove the extra entry and optimization.splitChunks from our configuration in the above example as they won't be needed for this next demonstration:
webpack.config.js
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
mode: 'development',
entry: {
index: './src/index.js',
- another: './src/another-module.js',
},
output: {
filename: '[name].bundle.js',
path: path.resolve(__dirname, 'dist'),
},
- optimization: {
- splitChunks: {
- chunks: 'all',
- },
- },
};We'll also update our project to remove the now unused files:
project
webpack-demo
├── package.json
├── package-lock.json
├── webpack.config.js
├── /dist
├── /src
│ ├── index.js
- │ └── another-module.js
└── /node_modulesNow, instead of statically importing lodash, we'll use dynamic importing to separate a chunk:
src/index.js
-import _ from 'lodash';
-
-function component() {
+function getComponent() {
- const element = document.createElement('div');
- // Lodash, now imported by this script
- element.innerHTML = _.join(['Hello', 'webpack'], ' ');
+ return import('lodash')
+ .then(({ default: _ }) => {
+ const element = document.createElement('div');
+
+ element.innerHTML = _.join(['Hello', 'webpack'], ' ');
- return element;
+ return element;
+ })
+ .catch((error) => 'An error occurred while loading the component');
}
-document.body.appendChild(component());
+getComponent().then((component) => {
+ document.body.appendChild(component);
+});The reason we need default is that since webpack 4, when importing a CommonJS module, the import will no longer resolve to the value of module.exports, it will instead create an artificial namespace object for the CommonJS module. For more information on the reason behind this, read webpack 4: import() and CommonJs.
Let's run webpack to see lodash separated out to a separate bundle:
...
[webpack-cli] Compilation finished
asset vendors-node_modules_lodash_lodash_js.bundle.js 549 KiB [compared for emit] (id hint: vendors)
asset index.bundle.js 13.5 KiB [compared for emit] (name: index)
runtime modules 7.37 KiB 11 modules
cacheable modules 530 KiB
./src/index.js 434 bytes [built] [code generated]
./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
webpack 5.x.x compiled successfully in 268 msimport(
/* webpackExports: ["default", "namedExport"] */
"./module"
);This can help webpack tree shake other unused exports. See Magic Comments for details.
As import() returns a promise, it can be used with async functions. Here's how it would simplify the code:
src/index.js
-function getComponent() {
+async function getComponent() {
+ const element = document.createElement('div');
+ const { default: _ } = await import('lodash');
- return import('lodash')
- .then(({ default: _ }) => {
- const element = document.createElement('div');
+ element.innerHTML = _.join(['Hello', 'webpack'], ' ');
- element.innerHTML = _.join(['Hello', 'webpack'], ' ');
-
- return element;
- })
- .catch((error) => 'An error occurred while loading the component');
+ return element;
}
getComponent().then((component) => {
document.body.appendChild(component);
});Understanding ChunkLoadError
When using dynamic import() or code splitting, webpack may throw a ChunkLoadError if a chunk fails to load at runtime.
This error typically indicates that the requested chunk could not be executed or resolved properly. In some cases, the browser’s underlying network or script loading error may not be fully reflected in the ChunkLoadError message itself.
If you encounter this error:
- Verify that the chunk file is accessible via the network.
- Check that the
publicPathis correctly configured. - Inspect the browser console for additional script or network errors.
For more context, see related discussion in the webpack issue tracker.
Prefetching/Preloading modules
Webpack 4.6.0+ adds support for prefetching and preloading.
Using these inline directives while declaring your imports allows webpack to output “Resource Hint” which tells the browser that for:
- prefetch: resource is probably needed for some navigation in the future
- preload: resource will also be needed during the current navigation
An example of this is having a HomePage component, which renders a LoginButton component which then on demand loads a LoginModal component after being clicked.
LoginButton.js
// ...
import(/* webpackPrefetch: true */ "./path/to/LoginModal.js");This will result in <link rel="prefetch" href="login-modal-chunk.js"> being appended in the head of the page, which will instruct the browser to prefetch in idle time the login-modal-chunk.js file.
Preload directive has a bunch of differences compared to prefetch:
- A preloaded chunk starts loading in parallel to the parent chunk. A prefetched chunk starts after the parent chunk finishes loading.
- A preloaded chunk has medium priority and is instantly downloaded. A prefetched chunk is downloaded while the browser is idle.
- A preloaded chunk should be instantly requested by the parent chunk. A prefetched chunk can be used anytime in the future.
- Browser support is different.
An example of this can be having a Component which always depends on a big library that should be in a separate chunk.
Let's imagine a component ChartComponent which needs a huge ChartingLibrary. It displays a LoadingIndicator when rendered and instantly does an on demand import of ChartingLibrary:
ChartComponent.js
// ...
import(/* webpackPreload: true */ "ChartingLibrary");When a page which uses the ChartComponent is requested, the charting-library-chunk is also requested via <link rel="preload">. Assuming the page-chunk is smaller and finishes faster, the page will be displayed with a LoadingIndicator, until the already requested charting-library-chunk finishes. This will give a little load time boost since it only needs one round-trip instead of two. Especially in high-latency environments.
Sometimes you need to have your own control over preload. For example, preload of any dynamic import can be done via async script. This can be useful in case of streaming server side rendering.
const lazyComp = () =>
import("DynamicComponent").catch((error) => {
// Do something with the error.
// For example, we can retry the request in case of any net error
});If the script loading will fail before webpack starts loading of that script by itself (webpack creates a script tag to load its code, if that script is not on a page), that catch handler won't start till chunkLoadTimeout is not passed. This behavior can be unexpected. But it's explainable — webpack can not throw any error, cause webpack doesn't know, that script failed. Webpack will add onerror handler to the script right after the error has happen.
To prevent such problem you can add your own onerror handler, which removes the script in case of any error:
<script
src="https://example.com/dist/dynamicComponent.js"
async
onerror="this.remove()"
></script>In that case, errored script will be removed. Webpack will create its own script and any error will be processed without any timeouts.
Bundle Analysis
Once you start splitting your code, it can be useful to analyze the output to check where modules have ended up. The official analyze tool is a good place to start. There are some other community-supported options out there as well:
- webpack-chart: Interactive pie chart for webpack stats.
- webpack-visualizer: Visualize and analyze your bundles to see which modules are taking up space and which might be duplicates.
- webpack-bundle-analyzer: A plugin and CLI utility that represents bundle content as a convenient interactive zoomable treemap.
- webpack bundle optimize helper: This tool will analyze your bundle and give you actionable suggestions on what to improve to reduce your bundle size.
- bundle-stats: Generate a bundle report(bundle size, assets, modules) and compare the results between different builds.
- webpack-stats-viewer: A plugin with build for webpack stats. Show more information about webpack bundle detail.
Next Steps
See Lazy Loading for a more concrete example of how import() can be used in a real application and Caching to learn how to split code more effectively.
Caching
So we're using webpack to bundle our modular application which yields a deployable /dist directory. Once the contents of /dist have been deployed to a server, clients (typically browsers) will hit that server to grab the site and its assets. The last step can be time consuming, which is why browsers use a technique called caching. This allows sites to load faster with less unnecessary network traffic. However, it can also cause headaches when you need new code to be picked up.
This guide focuses on the configuration needed to ensure files produced by webpack compilation can remain cached unless their content has changed.
Output Filenames
We can use the output.filename substitutions setting to define the names of our output files. Webpack provides a method of templating the filenames using bracketed strings called substitutions. The [contenthash] substitution will add a unique hash based on the content of an asset. When the asset's content changes, [contenthash] will change as well.
Let's get our project set up using the example from getting started, letting webpack write the page for us as in output management, so we don't have to deal with maintaining our index.html file manually:
project
webpack-demo
├── package.json
├── package-lock.json
├── webpack.config.js
├── /dist
├── /src
│ └── index.js
└── /node_moduleswebpack.config.js
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: './src/index.js',
+ experiments: {
+ html: true,
+ },
output: {
+ html: {
+ title: 'Caching',
+ },
- filename: 'bundle.js',
+ filename: '[name].[contenthash].js',
+ htmlFilename: 'index.html',
path: path.resolve(__dirname, 'dist'),
clean: true,
},
};output.html writes a document around the entry and injects its chunks, so the page tracks the hashed filenames on every build.
Running our build script, npm run build, with this configuration should produce the following output:
...
Asset Size Chunks Chunk Names
main.7e2c49a622975ebd9b7e.js 544 kB 0 [emitted] [big] main
index.html 197 bytes [emitted]
...As you can see the bundle's name now reflects its content (via the hash). If we run another build without making any changes, we'd expect that filename to stay the same. However, if we were to run it again, we may find that this is not the case:
...
Asset Size Chunks Chunk Names
main.205199ab45963f6a62ec.js 544 kB 0 [emitted] [big] main
index.html 197 bytes [emitted]
...This is because webpack includes certain boilerplate, specifically the runtime and manifest, in the entry chunk.
Extracting Boilerplate
As we learned in code splitting, the SplitChunksPlugin can be used to split modules out into separate bundles. Webpack provides an optimization feature to split runtime code into a separate chunk using the optimization.runtimeChunk option. Set it to single to create a single runtime bundle for all chunks:
webpack.config.js
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: './src/index.js',
experiments: {
html: true,
},
output: {
html: {
title: 'Caching',
},
filename: '[name].[contenthash].js',
htmlFilename: 'index.html',
path: path.resolve(__dirname, 'dist'),
clean: true,
},
+ optimization: {
+ runtimeChunk: 'single',
+ },
};Let's run another build to see the extracted runtime bundle:
Hash: 82c9c385607b2150fab2
Version: webpack 4.12.0
Time: 3027ms
Asset Size Chunks Chunk Names
runtime.cc17ae2a94ec771e9221.js 1.42 KiB 0 [emitted] runtime
main.e81de2cf758ada72f306.js 69.5 KiB 1 [emitted] main
index.html 275 bytes [emitted]
[1] (webpack)/buildin/module.js 497 bytes {1} [built]
[2] (webpack)/buildin/global.js 489 bytes {1} [built]
[3] ./src/index.js 309 bytes {1} [built]
+ 1 hidden moduleIt's also good practice to extract third-party libraries, such as lodash or react, to a separate vendor chunk as they are less likely to change than our local source code. This step will allow clients to request even less from the server to stay up to date.
This can be done by using the cacheGroups option of the SplitChunksPlugin demonstrated in Example 2 of SplitChunksPlugin. Lets add optimization.splitChunks with cacheGroups with next params and build:
webpack.config.js
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: './src/index.js',
experiments: {
html: true,
},
output: {
html: {
title: 'Caching',
},
filename: '[name].[contenthash].js',
htmlFilename: 'index.html',
path: path.resolve(__dirname, 'dist'),
clean: true,
},
optimization: {
runtimeChunk: 'single',
+ splitChunks: {
+ cacheGroups: {
+ vendor: {
+ test: /[\\/]node_modules[\\/]/,
+ name: 'vendors',
+ chunks: 'all',
+ },
+ },
+ },
},
};Let's run another build to see our new vendor bundle:
...
Asset Size Chunks Chunk Names
runtime.cc17ae2a94ec771e9221.js 1.42 KiB 0 [emitted] runtime
vendors.a42c3ca0d742766d7a28.js 69.4 KiB 1 [emitted] vendors
main.abf44fedb7d11d4312d7.js 240 bytes 2 [emitted] main
index.html 353 bytes [emitted]
...We can now see that our main bundle does not contain vendor code from node_modules directory and is down in size to 240 bytes!
Module Identifiers
Let's add another module, print.js, to our project:
project
webpack-demo
├── package.json
├── package-lock.json
├── webpack.config.js
├── /dist
├── /src
│ ├── index.js
+│ └── print.js
└── /node_modulesprint.js
+ export default function print(text) {
+ console.log(text);
+ };src/index.js
import _ from 'lodash';
+ import Print from './print';
function component() {
const element = document.createElement('div');
// Lodash, now imported by this script
element.innerHTML = _.join(['Hello', 'webpack'], ' ');
+ element.onclick = Print.bind(null, 'Hello webpack!');
return element;
}
document.body.appendChild(component());Running another build, we would expect only our main bundle's hash to change, however...
...
Asset Size Chunks Chunk Names
runtime.1400d5af64fc1b7b3a45.js 5.85 kB 0 [emitted] runtime
vendor.a7561fb0e9a071baadb9.js 541 kB 1 [emitted] [big] vendor
main.b746e3eb72875af2caa9.js 1.22 kB 2 [emitted] main
index.html 352 bytes [emitted]
...... we can see that all three have. This happens when each module.id is assigned from the resolving order (optimization.moduleIds: 'natural'), which webpack 5 only does by default for mode: 'none'. Meaning when the order of resolving is changed, the IDs will be changed as well. To recap:
- The
mainbundle changed because of its new content. - The
vendorbundle changed because itsmodule.idwas changed. - And, the
runtimebundle changed because it now contains a reference to a new module.
The first and last are expected, it's the vendor hash we want to fix. production mode already defaults optimization.moduleIds to 'deterministic' and development mode to 'named', both stable across builds; setting 'deterministic' explicitly keeps the ids stable regardless of mode:
webpack.config.js
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: './src/index.js',
experiments: {
html: true,
},
output: {
html: {
title: 'Caching',
},
filename: '[name].[contenthash].js',
htmlFilename: 'index.html',
path: path.resolve(__dirname, 'dist'),
clean: true,
},
optimization: {
+ moduleIds: 'deterministic',
runtimeChunk: 'single',
splitChunks: {
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
},
},
},
},
};Now, despite any new local dependencies, our vendor hash should stay consistent between builds:
...
Asset Size Chunks Chunk Names
main.216e852f60c8829c2289.js 340 bytes 0 [emitted] main
vendors.55e79e5927a639d21a1b.js 69.5 KiB 1 [emitted] vendors
runtime.725a1a51ede5ae0cfde0.js 1.42 KiB 2 [emitted] runtime
index.html 353 bytes [emitted]
Entrypoint main = runtime.725a1a51ede5ae0cfde0.js vendors.55e79e5927a639d21a1b.js main.216e852f60c8829c2289.js
...And let's modify our src/index.js to temporarily remove that extra dependency:
src/index.js
import _ from 'lodash';
- import Print from './print';
+ // import Print from './print';
function component() {
const element = document.createElement('div');
// Lodash, now imported by this script
element.innerHTML = _.join(['Hello', 'webpack'], ' ');
- element.onclick = Print.bind(null, 'Hello webpack!');
+ // element.onclick = Print.bind(null, 'Hello webpack!');
return element;
}
document.body.appendChild(component());And finally run our build again:
...
Asset Size Chunks Chunk Names
main.ad717f2466ce655fff5c.js 274 bytes 0 [emitted] main
vendors.55e79e5927a639d21a1b.js 69.5 KiB 1 [emitted] vendors
runtime.725a1a51ede5ae0cfde0.js 1.42 KiB 2 [emitted] runtime
index.html 353 bytes [emitted]
Entrypoint main = runtime.725a1a51ede5ae0cfde0.js vendors.55e79e5927a639d21a1b.js main.ad717f2466ce655fff5c.js
...We can see that both builds yielded 55e79e5927a639d21a1b in the vendor bundle's filename.
Deploying a new build
Content hashes make every deploy emit a new set of filenames, and that interacts with code splitting in a way worth planning for: a visitor who loaded the page before the deploy is running the old entry chunk, which asks for the old filename of an async chunk. If that file is gone from the server, the import() rejects and the application sees a ChunkLoadError — usually as a route or a lazily loaded widget that fails for exactly as long as the tab stays open.
The fix is on the deployment side, not in the configuration. Two measures cover it:
Keep the previous build's assets around. Serving old files costs little and makes the error disappear for anyone who loaded the page before the deploy. Copy the new build over the old one instead of replacing the directory; if you clean the output directory on build, output.clean accepts a keep predicate, and a CDN can expire the old objects after a day or so rather than on the next deploy.
Handle the rejection anyway. No retention window covers a tab left open over a weekend, so treat a failed chunk load as "this page is out of date" and reload it:
async function loadWidget() {
try {
return await import("./widget.js");
} catch {
// the deploy removed the chunk this build asks for
globalThis.location.reload();
}
}Two related options: output.chunkLoadTimeout sets how long webpack waits before rejecting, and output.crossOriginLoading is needed to get a useful error rather than an opaque one when chunks are served from a different origin.
Conclusion
Caching can be complicated, but the benefit to application or site users makes it worth the effort. See the Further Reading section below to learn more.
Authoring Libraries
Aside from applications, webpack can also be used to bundle JavaScript libraries. The following guide is meant for library authors looking to streamline their bundling strategy.
Authoring a Library
Let's assume that we are writing a small library, webpack-numbers, that allows users to convert the numbers 1 through 5 from their numeric representation to a textual one and vice-versa, e.g. 2 to 'two'.
The basic project structure would look like this:
project
+ ├── webpack.config.js
+ ├── package.json
+ └── /src
+ ├── index.js
+ └── ref.jsonInitialize the project with npm, then install webpack, webpack-cli, and lodash as development dependencies:
npm init -y
npm install --save-dev webpack webpack-cli lodashWe install lodash as a devDependency because we will initially bundle it into our library. Since it is included in the final output, consumers of our library won't need to install it themselves.
src/ref.json
[
{
"num": 1,
"word": "One"
},
{
"num": 2,
"word": "Two"
},
{
"num": 3,
"word": "Three"
},
{
"num": 4,
"word": "Four"
},
{
"num": 5,
"word": "Five"
},
{
"num": 0,
"word": "Zero"
}
]src/index.js
import _ from "lodash";
import numRef from "./ref.json";
export function numToWord(num) {
return _.reduce(
numRef,
(accum, ref) => (ref.num === num ? ref.word : accum),
"",
);
}
export function wordToNum(word) {
return _.reduce(
numRef,
(accum, ref) => (ref.word === word && word.toLowerCase() ? ref.num : accum),
-1,
);
}Webpack Configuration
Let's start with this basic webpack configuration:
webpack.config.js
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: "./src/index.js",
output: {
path: path.resolve(__dirname, "dist"),
filename: "webpack-numbers.js",
},
};In the above example, we're telling webpack to bundle src/index.js into dist/webpack-numbers.js.
Adding Source Maps
When bundling a library, it is recommended to generate source maps. Source maps
allow consumers of your library to debug your original source code rather than
the minified bundle. This can be done using the
devtool option:
webpack.config.js
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: './src/index.js',
+ devtool: 'source-map',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'webpack-numbers.js',
},
};Expose the Library
So far everything should be the same as bundling an application, and here comes the different part – we need to expose exports from the entry point through output.library option.
webpack.config.js
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'webpack-numbers.js',
+ library: 'webpackNumbers',
},
};We exposed the entry point as webpackNumbers so users can use it through script tag:
<script src="https://example.org/webpack-numbers.js"></script>
<script>
window.webpackNumbers.wordToNum("Five");
</script>However it only works when it's referenced through script tag, it can't be used in other environments like CommonJS, AMD, Node.js, etc.
As a library author, we want it to be compatible in different environments, i.e., users should be able to consume the bundled library in multiple ways listed below:
-
CommonJS module require:
const webpackNumbers = require("webpack-numbers"); // ... webpackNumbers.wordToNum("Two"); -
AMD module require:
require(["webpackNumbers"], (webpackNumbers) => { // ... webpackNumbers.wordToNum("Two"); }); -
script tag:
<!DOCTYPE html> <html> ... <script src="https://example.org/webpack-numbers.js"></script> <script> // ... // Global variable webpackNumbers.wordToNum("Five"); // Property in the window object window.webpackNumbers.wordToNum("Five"); // ... </script> </html>
Let's update the output.library option with its type set to 'umd':
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'webpack-numbers.js',
- library: 'webpackNumbers',
+ globalObject: 'this',
+ library: {
+ name: 'webpackNumbers',
+ type: 'umd',
+ },
},
};Now webpack will bundle a library that can work with CommonJS, AMD, and script tag.
Externalize Lodash
Now, if you run npx webpack, you will find that a largish bundle is created. If you inspect the file, you'll see that lodash has been bundled along with your code. To avoid bundling lodash and bloating our library, we can configure webpack to treat it as an external module. Since we are no longer bundling it, the consumer will need to provide it. Therefore, you should move lodash from devDependencies to dependencies (or peerDependencies) so package managers will install it automatically for consumers of your library.
This can be done using the externals configuration:
webpack.config.js
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'webpack-numbers.js',
library: {
name: 'webpackNumbers',
type: 'umd',
},
},
+ externals: {
+ lodash: {
+ commonjs: 'lodash',
+ commonjs2: 'lodash',
+ amd: 'lodash',
+ root: '_',
+ },
+ },
};This means that your library expects a dependency named lodash to be available in the consumer's environment.
External Limitations
For libraries that use several files from a dependency:
import A from "library/one";
import B from "library/two";
// ...You won't be able to exclude them from the bundle by specifying library in the externals. You'll either need to exclude them one by one or by using a regular expression.
export default {
// ...
externals: [
"library/one",
"library/two",
// Everything that starts with "library/"
/^library\/.+$/,
],
};Final Steps
Optimize your output for production by following the steps mentioned in the production guide. Let's also add the path to your generated bundle as the package's main field in with the package.json
package.json
{
...
"main": "dist/webpack-numbers.js",
...
}Or, to add it as a standard module as per this guide:
{
...
"module": "src/index.js",
...
}The key main refers to the standard from package.json, and module to a proposal to allow the JavaScript ecosystem upgrade to use ES2015 modules without breaking backwards compatibility.
Now you can publish it as an npm package and find it at unpkg.com to distribute it to your users.
Environment Variables
To disambiguate in your webpack.config.js between development and production builds you may use environment variables.
The webpack command line environment option --env allows you to pass in as many environment variables as you like. Environment variables will be made accessible in your webpack.config.js. For example, --env production or --env goal=local.
npx webpack --env goal=local --env production --progressThere is one change that you will have to make to your webpack config. Typically, export default points to the configuration object. To use the env variable, you must make export default a function:
webpack.config.js
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default (env) => {
// Use env.<YOUR VARIABLE> here:
console.log("Goal:", env.goal); // 'local'
console.log("Production:", env.production); // true
return {
entry: "./src/index.js",
output: {
filename: "bundle.js",
path: path.resolve(__dirname, "dist"),
},
};
};Build Performance
This guide contains some useful tips for improving build/compilation performance.
General
The following best practices should help, whether you're running build scripts in development or production.
Stay Up to Date
Use the latest webpack version. We are always making performance improvements. The latest recommended version of webpack is:
Staying up-to-date with Node.js can also help with performance. On top of this, keeping your package manager (e.g. npm or yarn) up-to-date can also help. Newer versions create more efficient module trees and increase resolving speed.
Loaders
Apply loaders to the minimal number of modules necessary. Instead of:
export default {
// ...
module: {
rules: [
{
test: /\.js$/,
loader: "babel-loader",
},
],
},
};Use the include field to only apply the loader modules that actually need to be transformed by 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: /\.js$/,
include: path.resolve(__dirname, "src"),
loader: "babel-loader",
},
],
},
};Bootstrap
Each additional loader/plugin has a bootup time. Try to use as few tools as possible.
Resolving
The following steps can increase resolving speed:
- Minimize the number of items in
resolve.modules,resolve.extensions,resolve.mainFiles,resolve.descriptionFiles, as they increase the number of filesystem calls. - Set
resolve.symlinks: falseif you don't use symlinks (e.g.npm linkoryarn link). - Set
resolve.cacheWithContext: falseif you use custom resolving plugins, that are not context specific.
Dlls
Use the DllPlugin to move code that is changed less often into a separate compilation. This will improve the application's compilation speed, although it does increase complexity of the build process.
Smaller = Faster
Decrease the total size of the compilation to increase build performance. Try to keep chunks small.
- Use fewer/smaller libraries.
- Use the
SplitChunksPluginin Multi-Page Applications. - Use the
SplitChunksPlugininasyncmode in Multi-Page Applications. - Remove unused code.
- Only compile the part of the code you are currently developing on.
Worker Pool
The thread-loader can be used to offload expensive loaders to a worker pool.
Persistent cache
Use cache option in webpack configuration. Clear cache directory on "postinstall" in package.json.
Custom plugins/loaders
Profile them to not introduce a performance problem here.
Progress plugin
It is possible to shorten build times by removing ProgressPlugin from webpack's configuration. Keep in mind, ProgressPlugin might not provide as much value for fast builds as well, so make sure you are leveraging the benefits of using it.
Development
The following steps are especially useful in development.
Incremental Builds
Use webpack's watch mode. Don't use other tools to watch your files and invoke webpack. The built-in watch mode will keep track of timestamps and passes this information to the compilation for cache invalidation.
In some setups, watching falls back to polling mode. With many watched files, this can cause a lot of CPU load. In these cases, you can increase the polling interval with watchOptions.poll.
Compile in Memory
The following utilities improve performance by compiling and serving assets in memory rather than writing to disk:
webpack-dev-serverwebpack-hot-middlewarewebpack-dev-middleware
stats.toJson speed
Webpack 4 outputs a large amount of data with its stats.toJson() by default. Avoid retrieving portions of the stats object unless necessary in the incremental step. webpack-dev-server after v3.1.3 contained a substantial performance fix to minimize the amount of data retrieved from the stats object per incremental build step.
Devtool
Be aware of the performance differences between the different devtool settings.
"eval"has the best performance, but doesn't assist you for transpiled code.- The
cheap-source-mapvariants are more performant if you can live with the slightly worse mapping quality. - Use a
eval-source-mapvariant for incremental builds.
Avoid Production Specific Tooling
Certain utilities, plugins, and loaders only make sense when building for production. For example, it usually doesn't make sense to minify and mangle your code with the MinimizerPlugin while in development. These tools should typically be excluded in development:
MinimizerPlugin[fullhash]/[chunkhash]/[contenthash]AggressiveSplittingPluginAggressiveMergingPluginModuleConcatenationPlugin
Minimal Entry Chunk
Webpack only emits updated chunks to the filesystem. For some configuration options, (HMR, [name]/[chunkhash]/[contenthash] in output.chunkFilename, [fullhash]) the entry chunk is invalidated in addition to the changed chunks.
Make sure the entry chunk is cheap to emit by keeping it small. The following configuration creates an additional chunk for the runtime code, so it's cheap to generate:
export default {
// ...
optimization: {
runtimeChunk: true,
},
};Avoid Extra Optimization Steps
Webpack does extra algorithmic work to optimize the output for size and load performance. These optimizations are performant for smaller codebases, but can be costly in larger ones:
export default {
// ...
optimization: {
removeAvailableModules: false,
removeEmptyChunks: false,
splitChunks: false,
},
};Output Without Path Info
Webpack has the ability to generate path info in the output bundle. However, this puts garbage collection pressure on projects that bundle thousands of modules. Turn this off in the options.output.pathinfo setting:
export default {
// ...
output: {
pathinfo: false,
},
};Node.js Versions 8.9.10-9.11.1
There was a performance regression in Node.js versions 8.9.10 - 9.11.1 in the ES2015 Map and Set implementations. Webpack uses those data structures liberally, so this regression affects compile times.
Earlier and later Node.js versions are not affected.
TypeScript Loader
To improve the build time when using ts-loader, use the transpileOnly loader option. On its own, this option turns off type checking. To gain type checking again, use the ForkTsCheckerWebpackPlugin. This speeds up TypeScript type checking and ESLint linting by moving each to a separate process.
export default {
// ...
test: /\.tsx?$/,
use: [
{
loader: "ts-loader",
options: {
transpileOnly: true,
},
},
],
};Production
The following steps are especially useful in production.
Source Maps
Source maps are really expensive. Do you really need them?
Specific Tooling Issues
The following tools have certain problems that can degrade build performance:
Babel
- Minimize the number of preset/plugins
TypeScript
- Use the
fork-ts-checker-webpack-pluginfor typechecking in a separate process. - Configure loaders to skip typechecking.
- Use the
ts-loaderinhappyPackMode: true/transpileOnly: true.
Sass
node-sasshas a bug which blocks threads from the Node.js thread pool. When using it with thethread-loadersetworkerParallelJobs: 2.
Content Security Policies
Webpack is capable of adding a nonce to all scripts that it loads. To activate this feature, set a __webpack_nonce__ variable and include it in your entry script. A unique hash-based nonce will then be generated and provided for each unique page view (this is why __webpack_nonce__ is specified in the entry file and not in the configuration). Please note that the __webpack_nonce__ should always be a base64-encoded string.
Examples
In the entry file:
// ...
__webpack_nonce__ = "c29tZSBjb29sIHN0cmluZyB3aWxsIHBvcCB1cCAxMjM=";
// ...Enabling CSP
Please note that CSPs are not enabled by default. A corresponding header Content-Security-Policy or meta tag <meta http-equiv="Content-Security-Policy" ...> needs to be sent with the document to instruct the browser to enable the CSP. Here's an example of what a CSP header including a CDN allow-listed URL might look like:
Content-Security-Policy: default-src 'self'; script-src 'self'
https://trusted.cdn.com;For more information on CSP and nonce attribute, please refer to Further Reading section at the bottom of this page.
Trusted Types
Webpack is also capable of using Trusted Types to load dynamically constructed scripts, to adhere to CSP require-trusted-types-for directive restrictions. See output.trustedTypes configuration option.
Development - Vagrant
If you have a more advanced project and use Vagrant to run your development environment in a Virtual Machine, you'll often want to also run webpack in the VM.
Configuring the Project
To start, make sure that the Vagrantfile has a static IP;
Vagrant.configure("2") do |config|
config.vm.network :private_network, ip: "10.10.10.61"
endNext, install webpack, webpack-cli, and webpack-dev-server in your project;
npm install --save-dev webpack webpack-cli webpack-dev-serverMake sure to have a webpack.config.js file. If you haven't already, use this as a minimal example to get started:
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
context: __dirname,
entry: "./app.js",
};And create an index.html file. The script tag should point to your bundle. If output.filename is not specified in the config, this will be main.js ([name].js for the default main entry).
<!DOCTYPE html>
<html>
<head>
<script src="/main.js" charset="utf-8"></script>
</head>
<body>
<h2>Hey!</h2>
</body>
</html>Note that you also need to create an app.js file.
Running the Server
Now, let's run the server:
webpack serve --host 0.0.0.0 --client-web-socket-url ws://10.10.10.61:8080/ws --watch-options-pollBy default, the server will only be accessible from localhost. We'll be accessing it from our host PC, so we need to change --host to allow this.
webpack-dev-server will include a script in your bundle that connects to a WebSocket to reload when a change in any of your files occurs.
The --client-web-socket-url flag makes sure the script knows where to look for the WebSocket. The server will use port 8080 by default, so we should also specify that here.
--watch-options-poll makes sure that webpack can detect changes in your files. By default, webpack listens to events triggered by the filesystem, but VirtualBox has many problems with this.
The server should be accessible on http://10.10.10.61:8080 now. If you make a change in app.js, it should live reload.
Advanced Usage with nginx
To mimic a more production-like environment, it is also possible to proxy the webpack-dev-server with nginx.
In your nginx configuration file, add the following:
server {
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
error_page 502 @start-webpack-dev-server;
}
location @start-webpack-dev-server {
default_type text/plain;
return 502 "Please start the webpack-dev-server first.";
}
}The proxy_set_header lines are important, because they allow the WebSockets to work correctly.
The command to start webpack-dev-server can then be changed to this:
webpack serve --client-web-socket-url ws://10.10.10.61:8080/ws --watch-options-pollThis makes the server only accessible on 127.0.0.1, which is fine because nginx takes care of making it available on your host PC.
Conclusion
We made the Vagrant box accessible from a static IP, and then made webpack-dev-server publicly accessible so it is reachable from a browser. We then tackled a common problem that VirtualBox doesn't send out filesystem events, causing the server to not reload on file changes.
Dependency Management
Dynamic expressions in import() or require()
A context is created if your request contains expressions, so the exact module is not known on compile time.
Example, given we have the following folder structure including .ejs files:
example_directory
└── template/
├── table.ejs
├── table-row.ejs
└── directory/
└── another.ejsWhen following import() or require() call is evaluated:
import(`./template/${name}.ejs`);
require(`./template/${name}.ejs`);Webpack parses the import() or require() call and extracts some information:
Directory: ./template
Regular expression: /^.*\.ejs$/context module
A context module is generated. It contains references to all modules in that directory that can be required with a request matching the regular expression. The context module contains a map which translates requests to module ids.
Example map:
{
"./table.ejs": 42,
"./table-row.ejs": 43,
"./directory/another.ejs": 44
}The context module also contains some runtime logic to access the map.
This means dynamic calls are supported but will cause all matching modules to be included in the bundle.
import.meta.webpackContext
The ESM equivalent of require.context is import.meta.webpackContext.
import.meta.webpackContext(directory, {
recursive: true,
regExp: /^\.\/.*$/,
mode: "sync",
});require.context
You can create your own context with the require.context() function.
It allows you to pass in a directory to search, a flag indicating whether subdirectories should be searched too, and a regular expression to match files against.
Webpack parses for require.context() in the code while building.
The syntax is as follows:
require.context(
directory,
(useSubdirectories = true),
(regExp = /^\.\/.*$/),
(mode = "sync"),
);Examples:
require.context("./test", false, /\.test\.js$/);
// a context with files from the test directory that can be required with a request ending with `.test.js`.require.context("../", true, /\.stories\.js$/);
// a context with all files in the parent folder and descending folders ending with `.stories.js`.context module API
A context module exports a (require) function that takes one argument: the request.
The exported function has 3 properties: resolve, keys, id.
resolveis a function and returns the module id of the parsed request.keysis a function that returns an array of all possible requests that the context module can handle.
This can be useful if you want to require all files in a directory or matching a pattern, Example:
function importAll(r) {
r.keys().forEach(r);
}
importAll(
import.meta.webpackContext("../components/", {
recursive: true,
regExp: /\.js$/,
}),
);const cache = {};
function importAll(r) {
for (const key of r.keys()) cache[key] = r(key);
}
importAll(
import.meta.webpackContext("../components/", {
recursive: true,
regExp: /\.js$/,
}),
);
// At build-time cache will be populated with all required modules.idis the module id of the context module. This may be useful forimport.meta.webpackHot.acceptormodule.hot.accept.
Installation
This guide goes through the various methods used to install webpack.
Prerequisites
Before we begin, make sure you have a fresh version of Node.js installed. The current Long Term Support (LTS) release is an ideal starting point. You may run into a variety of issues with the older versions as they may be missing functionality webpack and/or its related packages require.
Local Installation
The latest webpack release is:
To install the latest release or a specific version, run one of the following commands:
npm install --save-dev webpack
# or specific version
npm install --save-dev webpack@<version>If you're using webpack v4 or later and want to call webpack from the command line, you'll also need to install the CLI.
npm install --save-dev webpack-cliInstalling locally is what we recommend for most projects. This makes it easier to upgrade projects individually when breaking changes are introduced. Typically webpack is run via one or more npm scripts which will look for a webpack installation in your local node_modules directory:
"scripts": {
"build": "webpack --config webpack.config.js"
}Global Installation
The following NPM installation will make webpack available globally:
npm install --global webpackBleeding Edge
If you are enthusiastic about using the latest that webpack has to offer, you can install beta versions or even directly from the webpack repository using the following commands:
npm install --save-dev webpack@next
# or a specific tag/branch
npm install --save-dev webpack/webpack#<tagname/branchname>Hot Module Replacement
Hot Module Replacement (or HMR) is one of the most useful features offered by webpack. It allows all kinds of modules to be updated at runtime without the need for a full refresh. This page focuses on implementation while the concepts page gives more details on how it works and why it's useful.
Enabling HMR
This feature is great for productivity. All we need to do is update our webpack-dev-server configuration, and use webpack's built-in HMR plugin. We'll also drop the print.js tag from the page, as that module will now be consumed by index.js.
src/index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
- <title>Development</title>
- <script src="./print.js"></script>
+ <title>Hot Module Replacement</title>
</head>
<body>
<script src="./index.js"></script>
</body>
</html>Since webpack-dev-server v4.0.0, Hot Module Replacement is enabled by default.
webpack.config.js
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: './src/index.html',
experiments: {
html: true,
},
devtool: 'inline-source-map',
devServer: {
static: './dist',
+ hot: true,
},
output: {
filename: '[name].bundle.js',
htmlFilename: '[name].html',
path: path.resolve(__dirname, 'dist'),
clean: true,
},
};you can also provide manual entry points for HMR:
webpack.config.js
import path from 'node:path';
import { fileURLToPath } from 'node:url';
+ import webpack from 'webpack';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
- entry: './src/index.html',
+ entry: {
+ app: './src/index.html',
+ // Runtime code for hot module replacement
+ hot: 'webpack/hot/dev-server.js',
+ // Dev server client for web socket transport, hot and live reload logic
+ client: 'webpack-dev-server/client/index.js?hot=true&live-reload=true',
+ },
experiments: {
html: true,
},
devtool: 'inline-source-map',
devServer: {
static: './dist',
+ // Dev server client for web socket transport, hot and live reload logic
+ hot: false,
+ client: false,
},
+ plugins: [
+ // Plugin for hot module replacement
+ new webpack.HotModuleReplacementPlugin(),
+ ],
output: {
filename: '[name].bundle.js',
htmlFilename: '[name].html',
path: path.resolve(__dirname, 'dist'),
clean: true,
},
};Now let's update the index.js file so that when a change inside print.js is detected we tell webpack to accept the updated module.
index.js
import _ from 'lodash';
import printMe from './print.js';
function component() {
const element = document.createElement('div');
const btn = document.createElement('button');
element.innerHTML = _.join(['Hello', 'webpack'], ' ');
btn.innerHTML = 'Click me and check the console!';
btn.onclick = printMe;
element.appendChild(btn);
return element;
}
document.body.appendChild(component());
+
+ if (module.hot) {
+ module.hot.accept('./print.js', function() {
+ console.log('Accepting the updated printMe module!');
+ printMe();
+ })
+ }Start changing the console.log statement in print.js, and you should see the following output in the browser console (don't worry about that button.onclick = printMe output for now, we will also update that part later).
print.js
export default function printMe() {
- console.log('I get called from print.js!');
+ console.log('Updating print.js...');
}console
[HMR] Waiting for update signal from WDS...
main.js:4395 [WDS] Hot Module Replacement enabled.
+ 2main.js:4395 [WDS] App updated. Recompiling...
+ main.js:4395 [WDS] App hot update...
+ main.js:4330 [HMR] Checking for updates on the server...
+ main.js:10024 Accepting the updated printMe module!
+ 0.4b8ee77….hot-update.js:10 Updating print.js...
+ main.js:4330 [HMR] Updated modules:
+ main.js:4330 [HMR] - 20Via the Node.js API
When using Webpack Dev Server with the Node.js API, don't put the dev server options on the webpack configuration object. Instead, pass them as a second parameter upon creation. For example:
new WebpackDevServer(options, compiler)
To enable HMR, you also need to modify your webpack configuration object to include the HMR entry points. Here's a small example of how that might look:
dev-server.js
import path from "node:path";
import { fileURLToPath } from "node:url";
import webpack from "webpack";
import WebpackDevServer from "webpack-dev-server";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const config = {
mode: "development",
entry: [
// Runtime code for hot module replacement
"webpack/hot/dev-server.js",
// Dev server client for web socket transport, hot and live reload logic
"webpack-dev-server/client/index.js?hot=true&live-reload=true",
// Your entry
"./src/index.html",
],
experiments: {
html: true,
},
devtool: "inline-source-map",
plugins: [
// Plugin for hot module replacement
new webpack.HotModuleReplacementPlugin(),
],
output: {
filename: "[name].bundle.js",
htmlFilename: "[name].html",
path: path.resolve(__dirname, "dist"),
clean: true,
},
};
const compiler = webpack(config);
// `hot` and `client` options are disabled because we added them manually
const server = new WebpackDevServer({ hot: false, client: false }, compiler);
try {
await server.start();
console.log("dev server is running");
} catch (err) {
throw new Error(`Failed to start dev server: ${err.message}`, { cause: err });
}See the full documentation of webpack-dev-server Node.js API.
Gotchas
Hot Module Replacement can be tricky. To show this, let's go back to our working example. If you go ahead and click the button on the example page, you will realize the console is printing the old printMe function.
This is happening because the button's onclick event handler is still bound to the original printMe function.
To make this work with HMR we need to update that binding to the new printMe function using module.hot.accept:
index.js
import _ from 'lodash';
import printMe from './print.js';
function component() {
const element = document.createElement('div');
const btn = document.createElement('button');
element.innerHTML = _.join(['Hello', 'webpack'], ' ');
btn.innerHTML = 'Click me and check the console!';
btn.onclick = printMe; // onclick event is bind to the original printMe function
element.appendChild(btn);
return element;
}
- document.body.appendChild(component());
+ let element = component(); // Store the element to re-render on print.js changes
+ document.body.appendChild(element);
if (module.hot) {
module.hot.accept('./print.js', function() {
console.log('Accepting the updated printMe module!');
- printMe();
+ document.body.removeChild(element);
+ element = component(); // Re-render the "component" to update the click handler
+ document.body.appendChild(element);
})
}This is only one example, but there are many others that can easily trip people up. Luckily, there are a lot of loaders out there (some of which are mentioned below) that will make hot module replacement much easier.
What an update actually replaces
Most HMR confusion comes from one wrong assumption: that an update re-runs the module you wrote the accept call in. It does not.
When a module changes, webpack walks up from it through the modules that imported it until it finds one that accepted the change. Only the changed module is re-executed. The module that accepted it keeps running — the same instance, the same variables, the same closures — and its callback is invoked so it can react. accept('./dep.js', cb) therefore means "I will deal with ./dep.js changing", not "re-run me when it changes".
Two consequences follow, and they are the whole of HMR in practice:
- The accepting module's
disposehandler does not fire. Its handler runs only when that module is itself replaced, which happens when it changes or when it self-accepts. - Its imported bindings are already updated by the time the callback runs. Webpack rewires the import, so calling
dep()inside the callback calls the new version — there is nothing to re-import.
import { greet } from "./service.js";
// this runs once; it is NOT re-run when service.js changes
console.log(greet());
if (import.meta.webpackHot) {
import.meta.webpackHot.accept("./service.js", () => {
// `greet` already points at the new version here
console.log(greet());
});
}Self-accepting a module
A module that has no outward-visible bindings — it only causes effects when it runs — can accept its own changes. Webpack then re-executes that module in place and the update stops there instead of bubbling to the entry point.
import.meta.webpackHot.accept();This is what stylesheets do, and it is the right choice for things like a registered route table, a set of chart definitions, or a <canvas> render loop. It is the wrong choice when other modules hold on to what this one exported: they captured the old value at import time, and re-running the module does not update their copies. Accept in the module that owns the reference instead.
Cleaning up side effects
Re-executing a module runs its side effects a second time. An interval gets scheduled twice, a listener is registered twice, a socket is opened again — after ten edits the page has ten of everything and looks "slow" or "flickery" for reasons that have nothing to do with webpack.
dispose is the release valve. It runs before the new version executes, so it is where you undo what the current version claimed:
const socket = new WebSocket(url);
const timer = setInterval(poll, 1000);
const onResize = () => layout();
window.addEventListener("resize", onResize);
if (import.meta.webpackHot) {
import.meta.webpackHot.accept();
import.meta.webpackHot.dispose(() => {
socket.close();
clearInterval(timer);
window.removeEventListener("resize", onResize);
});
}The rule of thumb: anything that outlives the module's own evaluation — timers, listeners, sockets, observers, DOM nodes appended to document, entries added to a global registry — needs a matching line in dispose.
Preserving state across an update
The data object passed to dispose is handed to the next version of the module as import.meta.webpackHot.data, which is undefined on the first run. That round trip is how an edit keeps the state it should keep — a scroll position, a form draft, a game's score, the frame counter of an animation:
const previous = import.meta.webpackHot && import.meta.webpackHot.data;
let ticks = previous ? previous.ticks : 0;
const timer = setInterval(() => {
ticks += 1;
render(ticks);
}, 1000);
if (import.meta.webpackHot) {
import.meta.webpackHot.accept();
import.meta.webpackHot.dispose((data) => {
clearInterval(timer);
data.ticks = ticks;
});
}Edit this module and the counter carries on from where it was instead of restarting at zero — dispose stops the old interval and stashes the value, then the new version reads it back out of data.
Hot-swapping a singleton
A store, a router, a DI container: something created once that the whole application holds a reference to. Replacing the object itself would strand every holder on the old one, so replace its contents instead and leave the identity alone. A Redux store is the familiar version — replaceReducer exists for exactly this:
import { createStore } from "redux";
import rootReducer from "./reducers/index.js";
const store = createStore(rootReducer);
if (import.meta.webpackHot) {
import.meta.webpackHot.accept("./reducers/index.js", () => {
store.replaceReducer(rootReducer);
});
}
export default store;The store keeps its state and its subscribers; only the reducer function changes. Any singleton with a "swap the implementation" method — a router's route table, a registry's entries, an event bus's handlers — follows the same shape.
When an update can't be applied
If no module up the chain accepts a change, the update is aborted. webpack-dev-server responds by reloading the page, so a full reload during development usually means "nothing accepted this file", not a broken setup.
Two ways to steer that deliberately:
declinemarks a module as never hot-updatable, forcing the reload immediately rather than after a failed attempt. Useful for a module whose side effects genuinely cannot be undone.invalidateis for the conditional case: you accepted a dependency, but this particular change is one your callback cannot handle, so you hand the update to your parent instead.
import.meta.webpackHot.accept("./config.js", () => {
if (config.port !== runningPort) {
// a port change needs a restart; let it bubble
import.meta.webpackHot.invalidate();
return;
}
applyConfig(config);
});HMR on the server
HMR is not browser-only — it works under target: 'node', and is how a long-running server swaps request handlers without dropping its listening socket or in-memory state. There is no dev-server client here, so the bundle asks for updates itself, driven by a watching compiler that writes the update files:
import express from "express";
import handler from "./handler.js";
const app = express();
let current = handler;
// the indirection is what makes the swap possible:
// express keeps this closure, and the closure reads `current`
app.use((req, res, next) => current(req, res, next));
const server = app.listen(3000);
if (import.meta.webpackHot) {
import.meta.webpackHot.accept("./handler.js", () => {
current = handler;
});
import.meta.webpackHot.dispose(() => server.close());
setInterval(() => {
if (import.meta.webpackHot.status() === "idle") {
import.meta.webpackHot.check(true).catch((err) => {
console.error("HMR update failed, restart the server:", err.message);
});
}
}, 1000);
}check(true) downloads the update and applies it, resolving with the replaced modules — or with null when there is nothing new. It rejects when the update could not be applied, with a message naming the file, e.g. Aborted because ./handler.js is not accepted. On a server that is your cue to restart the process, since there is no page to reload.
HMR with Stylesheets
Hot Module Replacement with CSS needs no setup at all: experiments.css defaults to 'auto', so webpack handles stylesheets itself and patches them in place when they change — no loader to install, no rule to add. See Native CSS for what the built-in support covers.
Hot loading stylesheets can be done by importing them into a module:
project
webpack-demo
├── package.json
├── webpack.config.js
├── /dist
│ └── bundle.js
└── /src
├── index.html
├── index.js
├── print.js
+ └── styles.cssstyles.css
body {
background: blue;
}index.js
import _ from 'lodash';
import printMe from './print.js';
+ import './styles.css';
function component() {
const element = document.createElement('div');
const btn = document.createElement('button');
element.innerHTML = _.join(['Hello', 'webpack'], ' ');
btn.innerHTML = 'Click me and check the console!';
btn.onclick = printMe; // onclick event is bind to the original printMe function
element.appendChild(btn);
return element;
}
let element = component();
document.body.appendChild(element);
if (module.hot) {
module.hot.accept('./print.js', function() {
console.log('Accepting the updated printMe module!');
document.body.removeChild(element);
element = component(); // Re-render the "component" to update the click handler
document.body.appendChild(element);
})
}
Change the style on body to background: red; and you should immediately see the page's background color change without a full refresh.
styles.css
body {
- background: blue;
+ background: red;
}Other Code and Frameworks
There are many other loaders and examples out in the community to make HMR interact smoothly with a variety of frameworks and libraries...
- React Fast Refresh: Tweak React components in real time, preserving their state. It replaces React Hot Loader, which is deprecated and no longer maintained.
- Vue Loader: This loader supports HMR for vue components out of the box.
- Elm Hot webpack Loader: Supports HMR for the Elm programming language.
- Angular HMR: No loader necessary! HMR support is built in the Angular CLI, add the
--hmrflag to young servecommand. - Svelte Loader: This loader supports HMR for Svelte components out of the box.
Tree Shaking
Tree shaking is a term commonly used in the JavaScript context for dead-code elimination. It relies on the static structure of ES2015 module syntax, i.e. import and export. The name and concept have been popularized by the ES2015 module bundler rollup.
The webpack 2 release came with built-in support for ES2015 modules (alias harmony modules) as well as unused module export detection. The new webpack 4 release expands on this capability with a way to provide hints to the compiler via the "sideEffects" package.json property to denote which files in your project are "pure" and therefore safe to prune if unused.
Add a Utility
Let's add a new utility file to our project, src/math.js, that exports two functions:
project
webpack-demo
├── package.json
├── package-lock.json
├── webpack.config.js
├── /dist
│ ├── bundle.js
│ └── index.html
├── /src
│ ├── index.js
+ │ └── math.js
└── /node_modulessrc/math.js
export function square(x) {
return x * x;
}
export function cube(x) {
return x * x * x;
}Set the mode configuration option to development to make sure that the bundle is not minified:
webpack.config.js
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
},
+ mode: 'development',
+ optimization: {
+ usedExports: true,
+ },
};With that in place, let's update our entry script to utilize one of these new methods and remove lodash for simplicity:
src/index.js
- import _ from 'lodash';
+ import { cube } from './math.js';
function component() {
- const element = document.createElement('div');
+ const element = document.createElement('pre');
- // Lodash, now imported by this script
- element.innerHTML = _.join(['Hello', 'webpack'], ' ');
+ element.innerHTML = [
+ 'Hello webpack!',
+ '5 cubed is equal to ' + cube(5)
+ ].join('\n\n');
return element;
}
document.body.appendChild(component());Note that we did not import the square method from the src/math.js module. That function is what's known as "dead code", meaning an unused export that should be dropped. Now let's run our npm script, npm run build, and inspect the output bundle:
dist/bundle.js (around lines 5 - 25)
/***/ "./src/math.js"
/*!*********************!*\
!*** ./src/math.js ***!
\*********************/
(__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) {
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */ cube: () => (/* binding */ cube)
/* harmony export */ });
/* unused harmony export square */
function square(x) {
return x * x;
}
function cube(x) {
return x * x * x;
}
/***/ }Note the unused harmony export square comment above. If you look at the code below it, you'll notice that square is not being imported, however, it is still included in the bundle. We'll fix that in the next section.
Mark the file as side-effect-free
In a 100% ESM module world, identifying side effects is straightforward. However, we aren't there quite yet, so in the mean time it's necessary to provide hints to webpack's compiler on the "pureness" of your code.
The way this is accomplished is the "sideEffects" package.json property.
{
"name": "your-project",
"sideEffects": false
}All the code noted above does not contain side effects, so we can mark the property as false to inform webpack that it can safely prune unused exports.
If your code did have some side effects though, an array can be provided instead:
{
"name": "your-project",
"sideEffects": ["./src/some-side-effectful-file.js"]
}The array accepts simple glob patterns to the relevant files. It uses glob-to-regexp under the hood (Supports: *, **, {a,b}, [a-z]). Patterns like *.css, which do not include a /, will be treated like **/*.css.
{
"name": "your-project",
"sideEffects": ["./src/some-side-effectful-file.js", "*.css"]
}Finally, "sideEffects" can also be set from the module.rules configuration option.
Clarifying tree shaking and sideEffects
The sideEffects and usedExports (more known as tree shaking) optimizations are two different things.
sideEffects is much more effective since it allows to skip whole modules/files and the complete subtree.
usedExports relies on terser to detect side effects in statements. It is a difficult task in JavaScript and not as effective as straightforward sideEffects flag. It also can't skip subtree/dependencies since the spec says that side effects need to be evaluated. While exporting function works fine, React's Higher Order Components (HOC) are problematic in this regard.
If you're using dynamic import(), you can also use the webpackExports magic comment to specify the exports that should be exposed, allowing webpack to tree shake the others. See Magic Comments.
Let's make an example:
import { Button } from "@shopify/polaris";The pre-bundled version looks like this:
import hoistStatics from "hoist-non-react-statics";
function Button(_ref) {
// ...
}
function merge() {
const _final = {};
for (
let _len = arguments.length, objs = Array.from({ length: _len }), _key = 0;
_key < _len;
_key++
) {
objs[_key] = arguments[_key];
}
for (let _i = 0, _objs = objs; _i < _objs.length; _i++) {
const obj = _objs[_i];
mergeRecursively(_final, obj);
}
return _final;
}
function withAppProvider() {
return function addProvider(WrappedComponent) {
const WithProvider =
/*#__PURE__*/
(function (_React$Component) {
// ...
return WithProvider;
})(Component);
WithProvider.contextTypes = WrappedComponent.contextTypes
? merge(WrappedComponent.contextTypes, polarisAppProviderContextTypes)
: polarisAppProviderContextTypes;
const FinalComponent = hoistStatics(WithProvider, WrappedComponent);
return FinalComponent;
};
}
const Button$1 = withAppProvider()(Button);
export {
// ...,
Button$1,
};When Button is unused you can effectively remove the export { Button$1 }; which leaves all the remaining code. So the question is "Does this code have any side effects or can it be safely removed?". Difficult to say, especially because of this line withAppProvider()(Button). withAppProvider is called and the return value is also called. Are there any side effects when calling merge or hoistStatics? Are there side effects when assigning WithProvider.contextTypes (Setter?) or when reading WrappedComponent.contextTypes (Getter?).
Terser actually tries to figure it out, but it doesn't know for sure in many cases. This doesn't mean that terser is not doing its job well because it can't figure it out. It's too difficult to determine it reliably in a dynamic language like JavaScript.
But we can help terser by using the /*#__PURE__*/ annotation. It flags a statement as side effect free. So a small change would make it possible to tree-shake the code:
const Button$1 = /* #__PURE__ */ withAppProvider()(Button);This would allow to remove this piece of code. But there are still questions with the imports which need to be included/evaluated because they could contain side effects.
To tackle this, we use the "sideEffects" property in package.json.
It's similar to /*#__PURE__*/ but on a module level instead of a statement level. It says ("sideEffects" property): "If no direct export from a module flagged with no-sideEffects is used, the bundler can skip evaluating the module for side effects.".
In the Shopify's Polaris example, original modules look like this:
index.js
import "./configure";
export * from "./types";
export * from "./components";components/index.js
// ...
export { default as Breadcrumbs } from "./Breadcrumbs";
export { buttonFrom, buttonsFrom, default as Button } from "./Button";
export { default as ButtonGroup } from "./ButtonGroup";
// ...package.json
// ...
"sideEffects": [
"**/*.css",
"**/*.scss",
"./esnext/index.js",
"./esnext/configure.js"
],
// ...For import { Button } from "@shopify/polaris"; this has the following implications:
- include it: include the module, evaluate it and continue analysing dependencies
- skip over: don't include it, don't evaluate it but continue analysing dependencies
- exclude it: don't include it, don't evaluate it and don't analyse dependencies
Specifically per matching resource(s):
index.js: No direct export is used, but flagged with sideEffects -> include itconfigure.js: No export is used, but flagged with sideEffects -> include ittypes/index.js: No export is used, not flagged with sideEffects -> exclude itcomponents/index.js: No direct export is used, not flagged with sideEffects, but reexported exports are used -> skip overcomponents/Breadcrumbs.js: No export is used, not flagged with sideEffects -> exclude it. This also excluded all dependencies likecomponents/Breadcrumbs.csseven if they are flagged with sideEffects.components/Button.js: Direct export is used, not flagged with sideEffects -> include itcomponents/Button.css: No export is used, but flagged with sideEffects -> include it
In this case only 4 modules are included into the bundle:
index.js: pretty much emptyconfigure.jscomponents/Button.jscomponents/Button.css
After this optimization, other optimizations can still apply. For example: buttonFrom and buttonsFrom exports from Button.js are unused too. usedExports optimization will pick it up and terser may be able to drop some statements from the module.
Module Concatenation also applies. So that these 4 modules plus the entry module (and probably more dependencies) can be concatenated. index.js has no code generated in the end.
Full Example: Understanding Side Effects with CSS Files
To better understand the impact of the sideEffects flag, let's look at a complete example of an npm package with CSS assets and how they might be affected during tree shaking. We'll create a fictional UI component library called "awesome-ui".
Package Structure
Our example package looks like this:
awesome-ui/
├── package.json
└── dist/
├── index.js
├── components/
│ ├── index.js
│ ├── Button/
│ │ ├── index.js
│ │ └── Button.css
│ ├── Card/
│ │ ├── index.js
│ │ └── Card.css
│ └── Modal/
│ ├── index.js
│ └── Modal.css
└── theme/
├── index.js
└── defaultTheme.cssPackage Files Content
package.json
{
"name": "awesome-ui",
"version": "1.0.0",
"main": "dist/index.js",
"sideEffects": false
}dist/index.js
export * from "./components";
export * from "./theme";dist/components/index.js
export { default as Button } from "./Button";
export { default as Card } from "./Card";
export { default as Modal } from "./Modal";dist/components/Button/index.js
import "./Button.css"; // This has a side effect - it applies styles when imported!
export default function Button(props) {
// Button component implementation
return {
type: "button",
...props,
};
}dist/components/Button/Button.css
.awesome-ui-button {
background-color: #0078d7;
color: white;
padding: 8px 16px;
border-radius: 4px;
border: none;
cursor: pointer;
}dist/components/Card/index.js and dist/components/Modal/index.js would have similar structure.
dist/theme/index.js
import "./defaultTheme.css"; // This has a side effect!
export const themeColors = {
primary: "#0078d7",
secondary: "#f3f2f1",
danger: "#d13438",
};What Happens When Consuming This Package?
Now, imagine a consumer application that only wants to use the Button component:
import { Button } from "awesome-ui";
// Use the Button componentWith sideEffects: false in package.json
When webpack processes this import with tree shaking enabled:
- It sees the import for only Button
- It looks at the package.json and sees
sideEffects: false - It determines it only needs to include the Button component code
- Since all files are marked as having no side effects, it will include only the JavaScript code for the Button
- The CSS file import gets dropped! Even though Button.css is imported in Button/index.js, webpack assumes this import has no side effects.
The result: The Button component will render, but without any styling since Button.css was eliminated during tree shaking.
The Correct Configuration for This Package
To fix this, we need to update package.json to properly mark CSS files as having side effects:
{
"name": "awesome-ui",
"version": "1.0.0",
"main": "dist/index.js",
"sideEffects": ["**/*.css"]
}With this configuration:
- Webpack still identifies that only the Button component is needed
- But now it recognizes that CSS files have side effects
- So, it includes Button.css when processing Button/index.js
The Decision Tree for Side Effects
Here's how webpack evaluates modules during tree shaking:
-
Is the export from this module used directly or indirectly?
- If yes: Include the module
- If no: Continue to step 2
-
Is the module marked with side effects?
- If yes (
sideEffectsincludes this file or istrue): Include the module - If no (
sideEffectsisfalseor doesn't include this file): Exclude the module and its dependencies
- If yes (
For our library's files with the proper sideEffects configuration:
dist/index.js: No direct export used, no side effects -> Skip overdist/components/index.js: No direct export used, no side effects -> Skip overdist/components/Button/index.js: Direct export used -> Includedist/components/Button/Button.css: No exports, has side effects -> Includedist/components/Card/*: No exports used, no side effects -> Excludedist/components/Modal/*: No exports used, no side effects -> Excludedist/theme/*: No exports used, no side effects -> Exclude
Real-World Impact
The impact of incorrect side effects configuration can be significant:
- CSS not being included: Components render without styles
- Global JavaScript not running: Polyfills or global configurations don't execute
- Initialization code skipped: Functions that register components or set up event listeners never run
These issues can be particularly hard to debug because they often only appear in production builds when tree shaking is enabled.
Testing Side Effects Configuration
A good way to test if your side effects configuration is correct:
- Create a minimal application that imports just one component
- Build it with production settings (with tree shaking enabled)
- Check if all necessary styles and behaviors work correctly
- Look at the generated bundle to confirm the right files are included
Mark a function call as side-effect-free
It is possible to tell webpack that a function call is side-effect-free (pure) by using the /*#__PURE__*/ annotation. It can be put in front of function calls to mark them as side-effect-free. Arguments passed to the function are not being marked by the annotation and may need to be marked individually. When the initial value in a variable declaration of an unused variable is considered as side-effect-free (pure), it is getting marked as dead code, not executed and dropped by the minimizer.
This behavior is enabled when optimization.innerGraph is set to true.
file.js
/* #__PURE__ */ double(55);Mark a function declaration as side-effect-free
5.107.0+Webpack also supports the #__NO_SIDE_EFFECTS__ annotation to mark a function declaration as pure. Calls to a function annotated this way can be eliminated from the bundle when their return value is unused, even if the function body is not statically analyzable as pure. This is useful for factory or builder functions whose call sites would otherwise need a /*#__PURE__*/ annotation each time.
// utils.js
/*#__NO_SIDE_EFFECTS__*/
export function createLogger(prefix) {
return (msg) => console.log(`[${prefix}] ${msg}`);
}// app.js
import { createLogger } from "./utils";
// dropped, because `createLogger` is annotated and its result is unused
const unused = createLogger("debug");Minify the Output
So we've cued up our "dead code" to be dropped by using the import and export syntax, but we still need to drop it from the bundle. To do that, set the mode configuration option to production.
webpack.config.js
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
},
- mode: 'development',
- optimization: {
- usedExports: true,
- }
+ mode: 'production',
};With that squared away, we can run another npm run build and see if anything has changed.
Notice anything different about dist/bundle.js? The whole bundle is now minified and mangled, but, if you look carefully, you won't see the square function included but will see a mangled version of the cube function (function r(e){return e*e*e}n.a=r). With minification and tree shaking, our bundle is now a few bytes smaller! While that may not seem like much in this contrived example, tree shaking can yield a significant decrease in bundle size when working on larger applications with complex dependency trees.
Common Pitfalls with Side Effects
When working with tree shaking and the sideEffects flag, there are several common pitfalls to avoid:
1. Over-optimistic sideEffects: false
Setting sideEffects: false in your package.json is tempting for optimal tree shaking, but this can cause problems if your code actually does have side effects. Examples of hidden side effects:
- CSS imports (as demonstrated above)
- Polyfills that modify global objects
- Libraries that register global event listeners
- Code that modifies prototype chains
2. Re-exports with Side Effects
Consider this pattern:
// This file has side effects that might be skipped
import "./polyfill";
// Re-export components
export * from "./components";If a consumer only imports specific components, the polyfill import might be skipped entirely if not properly marked with side effects.
3. Forgetting about Nested Dependencies
Your package might correctly mark side effects, but if it depends on third-party packages that incorrectly mark their side effects, you might still encounter issues.
4. Testing Only in Development Mode
Tree shaking typically only fully activates in production mode. Testing only in development can hide tree shaking issues until deployment.
Conclusion
What we've learned is that in order to take advantage of tree shaking, you must...
- Use ES2015 module syntax (i.e.
importandexport). - Ensure no compilers transform your ES2015 module syntax into CommonJS modules (this is the default behavior of the popular Babel preset @babel/preset-env - see the documentation for more details).
- Add a
"sideEffects"property to your project'spackage.jsonfile. - Be careful about correctly marking files with side effects, especially CSS imports.
- Use the
productionmodeconfiguration option to enable various optimizations including minification and tree shaking (side effects optimization is enabled in development mode using the flag value). - Make sure you set a correct value for
devtoolas some of them can't be used inproductionmode.
You can imagine your application as a tree. The source code and libraries you actually use represent the green, living leaves of the tree. Dead code represents the brown, dead leaves of the tree that are consumed by autumn. In order to get rid of the dead leaves, you have to shake the tree, causing them to fall.
If you are interested in more ways to optimize your output, please jump to the next guide for details on building for production.
Production
In this guide, we'll dive into some of the best practices and utilities for building a production site or application.
Setup
The goals of development and production builds differ greatly. In development, we want strong source mapping and a localhost server with live reloading or hot module replacement. In production, our goals shift to a focus on minified bundles, lighter weight source maps, and optimized assets to improve load time. With this logical separation at hand, we typically recommend writing separate webpack configurations for each environment.
While we will separate the production and development specific bits out, note that we'll still maintain a "common" configuration to keep things DRY. In order to merge these configurations together, we'll use a utility called webpack-merge. With the "common" configuration in place, we won't have to duplicate code within the environment-specific configurations.
Let's start by installing webpack-merge and splitting out the bits we've already worked on in previous guides:
npm install --save-dev webpack-mergeproject
webpack-demo
├── package.json
├── package-lock.json
- ├── webpack.config.js
+ ├── webpack.common.js
+ ├── webpack.dev.js
+ ├── webpack.prod.js
├── /dist
├── /src
│ ├── index.js
│ └── math.js
└── /node_moduleswebpack.common.js
+ import path from 'node:path';
+ import { fileURLToPath } from 'node:url';
+
+ const __filename = fileURLToPath(import.meta.url);
+ const __dirname = path.dirname(__filename);
+
+ export default {
+ entry: {
+ app: './src/index.js',
+ },
+ experiments: {
+ html: true,
+ },
+ output: {
+ filename: '[name].bundle.js',
+ htmlFilename: 'index.html',
+ path: path.resolve(__dirname, 'dist'),
+ clean: true,
+ html: {
+ meta: {
+ charset: 'UTF-8',
+ viewport: 'width=device-width, initial-scale=1',
+ },
+ title: 'Production',
+ },
+ },
+ };webpack.dev.js
+ import { merge } from 'webpack-merge';
+ import common from './webpack.common.js';
+
+ export default merge(common, {
+ mode: 'development',
+ devtool: 'inline-source-map',
+ devServer: {
+ static: './dist',
+ },
+ });webpack.prod.js
+ import { merge } from 'webpack-merge';
+ import common from './webpack.common.js';
+
+ export default merge(common, {
+ mode: 'production',
+ });In webpack.common.js, we now have setup our entry and output configuration and we've included any plugins that are required for both environments. In webpack.dev.js, we've set mode to development. Also, we've added the recommended devtool for that environment (strong source mapping), as well as our devServer configuration. Finally, in webpack.prod.js,mode is set to production which loads MinimizerPlugin, which was first introduced by the tree shaking guide.
Note the use of merge() calls in the environment-specific configurations to include our common configuration in webpack.dev.js and webpack.prod.js. The webpack-merge tool offers a variety of advanced features for merging but for our use case we won't need any of that.
NPM Scripts
Now, let's modify our npm scripts to use the new configuration files. For the start script, which runs webpack-dev-server, we will use webpack.dev.js, and for the build script, which runs webpack to create a production build, we will use webpack.prod.js:
package.json
{
"name": "development",
"version": "1.0.0",
"description": "",
"main": "src/index.js",
"scripts": {
- "start": "webpack serve --open",
+ "start": "webpack serve --open --config webpack.dev.js",
- "build": "webpack"
+ "build": "webpack --config webpack.prod.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"csv-loader": "^3.0.5",
"express": "^5.2.1",
"webpack": "^5.105.0",
"webpack-cli": "^7.0.0",
"webpack-dev-middleware": "^8.0.3",
"webpack-dev-server": "^5.2.3",
"webpack-merge": "^6.0.1",
"xml-loader": "^1.2.1"
}
}Feel free to run those scripts and see how the output changes as we continue adding to our production configuration.
Specify the Mode
Many libraries will key off the process.env.NODE_ENV variable to determine what should be included in the library. For example, when process.env.NODE_ENV is not set to 'production' some libraries may add additional logging and testing to make debugging easier. However, with process.env.NODE_ENV set to 'production' they might drop or add significant portions of code to optimize how things run for your actual users. Since webpack v4, specifying mode automatically configures process.env.NODE_ENV for you through DefinePlugin:
webpack.prod.js
import { merge } from 'webpack-merge';
import common from './webpack.common.js';
export default merge(common, {
mode: 'production',
});If you're using a library like react, you should actually see a significant drop in bundle size after adding DefinePlugin. Also, note that any of our local /src code can key off of this as well, so the following check would be valid:
src/index.js
import { cube } from './math.js';
+
+ if (process.env.NODE_ENV !== 'production') {
+ console.log('Looks like we are in development mode!');
+ }
function component() {
const element = document.createElement('pre');
element.innerHTML = [
'Hello webpack!',
'5 cubed is equal to ' + cube(5)
].join('\n\n');
return element;
}
document.body.appendChild(component());Minification
Webpack v4+ will minify your code by default in production mode.
Note that while the MinimizerPlugin is a great place to start for minification and being used by default, there are other options out there:
If you decide to try another minification plugin, make sure your new choice also drops dead code as described in the tree shaking guide and provide it as the optimization.minimizer.
Source Mapping
We encourage you to have source maps enabled in production, as they are useful for debugging as well as running benchmark tests. That said, you should choose one with a fairly quick build speed that's recommended for production use (see devtool). For this guide, we'll use the source-map option in the production as opposed to the inline-source-map we used in the development:
webpack.prod.js
import { merge } from 'webpack-merge';
import common from './webpack.common.js';
export default merge(common, {
mode: 'production',
+ devtool: 'source-map',
});Minimize CSS
It is crucial to minimize your CSS for production, and webpack does it for you: optimization.minimize is on in production mode and covers CSS assets along with JavaScript, with no extra plugin to install. See Minification for what it does and how to tune each transform.
CLI Alternatives
Many of the options described above can be set as command line arguments. For example, optimization.minimize can be set with --optimization-minimize, and mode can be set with --mode. Run npx webpack --help=verbose for a full list of CLI arguments.
While these shorthand methods are useful, we recommend setting these options in a webpack configuration file for more configurability.
Lazy Loading
Lazy, or "on demand", loading is a great way to optimize your site or application. This practice essentially involves splitting your code at logical breakpoints, and then loading it once the user has done something that requires, or will require, a new block of code. This speeds up the initial load of the application and lightens its overall weight as some blocks may never even be loaded.
Dynamic Import Example
Let's take the example from Code Splitting and tweak it a bit to demonstrate this concept even more. The code there does cause a separate chunk, lodash.bundle.js, to be generated and technically "lazy-loads" it as soon as the script is run. The trouble is that no user interaction is required to load the bundle – meaning that every time the page is loaded, the request will fire. This doesn't help us too much and will impact performance negatively.
Let's try something different. We'll add an interaction to log some text to the console when the user clicks a button. However, we'll wait to load that code (print.js) until the interaction occurs for the first time. To do this we'll go back and rework the final Dynamic Imports example from Code Splitting and leave lodash in the main chunk.
project
webpack-demo
├── package.json
├── package-lock.json
├── webpack.config.js
├── /dist
├── /src
│ ├── index.js
+│ └── print.js
└── /node_modulessrc/print.js
console.log(
"The print.js module has loaded! See the network tab in dev tools...",
);
export default () => {
console.log('Button Clicked: Here\'s "some text"!');
};src/index.js
+ import _ from 'lodash';
+
- async function getComponent() {
+ function component() {
const element = document.createElement('div');
- const _ = await import(/* webpackChunkName: "lodash" */ 'lodash');
+ const button = document.createElement('button');
+ const br = document.createElement('br');
+ button.innerHTML = 'Click me and look at the console!';
element.innerHTML = _.join(['Hello', 'webpack'], ' ');
+ element.appendChild(br);
+ element.appendChild(button);
+
+ // Note that because a network request is involved, some indication
+ // of loading would need to be shown in a production-level site/app.
+ button.onclick = e => import(/* webpackChunkName: "print" */ './print').then(module => {
+ const print = module.default;
+
+ print();
+ });
return element;
}
- getComponent().then(component => {
- document.body.appendChild(component);
- });
+ document.body.appendChild(component());Now let's run webpack and check out our new lazy-loading functionality:
...
Asset Size Chunks Chunk Names
print.bundle.js 417 bytes 0 [emitted] print
index.bundle.js 548 kB 1 [emitted] [big] index
index.html 189 bytes [emitted]
...Defer Import Example
In some cases, it might be annoying or hard to convert all uses of a module to asynchronous, since it enforces the unnecessary asyncification of all functions, without providing the ability to only defer the synchronous evaluation work.
The TC39 proposal Deferring Module Evaluation is to solve this problem.
The proposal is to have a new syntactical import form which will only ever return a namespace exotic object. When used, the module and its dependencies would not be executed, but would be fully loaded to the point of being execution-ready before the module graph is considered loaded.
Only when accessing a property of this module, would the execution operations be performed (if needed).
This feature is available by enabling experiments.deferImport.
project
webpack-demo
├── package.json
├── package-lock.json
├── webpack.config.js
├── /dist
├── /src
│ ├── index.js
+ │ └── print.js
└── /node_modulessrc/print.js
console.log(
"The print.js module has loaded! See the network tab in dev tools...",
);
export default () => {
console.log('Button Clicked: Here\'s "some text"!');
};src/index.js
import _ from 'lodash';
+ import defer * as print from './print';
function component() {
const element = document.createElement('div');
const button = document.createElement('button');
const br = document.createElement('br');
button.innerHTML = 'Click me and look at the console!';
element.innerHTML = _.join(['Hello', 'webpack'], ' ');
element.appendChild(br);
element.appendChild(button);
- // Note that because a network request is involved, some indication
- // of loading would need to be shown in a production-level site/app.
+ // In this example, the print module is downloaded but not evaluated,
+ // so there is no network request involved after the button is clicked.
- button.onclick = e => import(/* webpackChunkName: "print" */ './print').then(module => {
+ button.onclick = e => {
const print = module.default;
+ // ^ The module is evaluated here.
print();
- });
+ };
return element;
}
getComponent().then(component => {
document.body.appendChild(component);
});
document.body.appendChild(component());This is similar to the CommonJS style of lazy loading:
src/index.js
import _ from 'lodash';
- import defer * as print from './print';
function component() {
const element = document.createElement('div');
const button = document.createElement('button');
const br = document.createElement('br');
button.innerHTML = 'Click me and look at the console!';
element.innerHTML = _.join(['Hello', 'webpack'], ' ');
element.appendChild(br);
element.appendChild(button);
// In this example, the print module is downloaded but not evaluated,
// so there is no network request involved after the button is clicked.
button.onclick = e => {
+ const print = require('./print');
+ // ^ The module is evaluated here.
const print = module.default;
- // ^ The module is evaluated here.
print();
};
return element;
}
getComponent().then(component => {
document.body.appendChild(component);
});
document.body.appendChild(component());Using import.defer() with Context Modules
5.105.0+import.defer() also works with context modules - the import path can be a dynamic expression. Webpack includes all matching modules in the module graph, but evaluation of the selected module is deferred until a property on the namespace object is first accessed.
The following example demonstrates deferred evaluation of locale modules using a dynamic context path:
src/locales/en.js
export const greeting = "Hello";src/locales/fr.js
export const greeting = "Bonjour";src/index.js
const language = navigator.language.split("-")[0]; // "en", "fr", etc.
// Resolves to a deferred namespace; nothing is evaluated yet.
const locale = await import.defer("./locales/" + language + ".js");
document.getElementById("btn").addEventListener("click", () => {
// The locale module is evaluated here, on first property access.
document.getElementById("output").textContent = locale.greeting;
});Webpack prepares all matching locale modules so they are ready for execution, but the selected module is only evaluated when locale.greeting is first accessed. This allows you to load multiple locale files without executing them immediately.
Frameworks
Many frameworks and libraries have their own recommendations on how this should be accomplished within their methodologies. Here are a few examples:
- React: Code Splitting and Lazy Loading
- Vue: Dynamic Imports in Vue.js for better performance
- Angular: Lazy Loading route configuration and AngularJS + webpack = lazyLoad
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.
Shimming
The webpack compiler can understand modules written as ES2015 modules, CommonJS or AMD. However, some third party libraries may expect global dependencies (e.g. $ for jQuery). The libraries might also create globals which need to be exported. These "broken modules" are one instance where shimming comes into play.
Another instance where shimming can be useful is when you want to polyfill browser functionality to support more users. In this case, you may only want to deliver those polyfills to the browsers that need patching (i.e. load them on demand).
The following article will walk through both of these use cases.
Shimming Globals
Let's start with the first use case of shimming global variables. Before we do anything let's take another look at our project:
project
webpack-demo
├── package.json
├── package-lock.json
├── webpack.config.js
├── /dist
│ └── index.html
├── /src
│ └── index.js
└── /node_modulesRemember that lodash package we were using? For demonstration purposes, let's say we wanted to instead provide this as a global throughout our application. To do this, we can use ProvidePlugin.
The ProvidePlugin makes a package available as a variable in every module compiled through webpack. If webpack sees that variable used, it will include the given package in the final bundle. Let's go ahead by removing the import statement for lodash and instead provide it via the plugin:
src/index.js
-import _ from 'lodash';
-
function component() {
const element = document.createElement('div');
- // Lodash, now imported by this script
element.innerHTML = _.join(['Hello', 'webpack'], ' ');
return element;
}
document.body.appendChild(component());webpack.config.js
import path from "node:path";
import { fileURLToPath } from 'url';
+import webpack from "webpack";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: './src/index.js',
output: {
filename: 'main.js',
path: path.resolve(__dirname, 'dist'),
},
+ plugins: [
+ new webpack.ProvidePlugin({
+ _: 'lodash',
+ }),
+ ],
};What we've essentially done here is tell webpack...
If you encounter at least one instance of the variable
_, include thelodashpackage and provide it to the modules that need it.
If we run a build, we should still see the same output:
$ npm run build
..
[webpack-cli] Compilation finished
asset main.js 69.1 KiB [emitted] [minimized] (name: main) 1 related asset
runtime modules 344 bytes 2 modules
cacheable modules 530 KiB
./src/index.js 191 bytes [built] [code generated]
./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
webpack 5.x.x compiled successfully in 2910 msWe can also use the ProvidePlugin to expose a single export of a module by configuring it with an "array path" (e.g. [module, child, ...children?]). So let's imagine we only wanted to provide the join method from lodash wherever it's invoked:
src/index.js
function component() {
const element = document.createElement('div');
- element.innerHTML = _.join(['Hello', 'webpack'], ' ');
+ element.innerHTML = join(['Hello', 'webpack'], ' ');
return element;
}
document.body.appendChild(component());webpack.config.js
import path from "node:path";
import webpack from "webpack";
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: './src/index.js',
output: {
filename: 'main.js',
path: path.resolve(__dirname, 'dist'),
},
plugins: [
new webpack.ProvidePlugin({
- _: 'lodash',
+ join: ['lodash', 'join'],
}),
],
};This would go nicely with Tree Shaking as the rest of the lodash library should get dropped.
Granular Shimming
Some legacy modules rely on this being the window object. Let's update our index.js so this is the case:
function component() {
const element = document.createElement('div');
element.innerHTML = join(['Hello', 'webpack'], ' ');
+ // Assume we are in the context of `window`
+ this.alert("Hmmm, this probably isn't a great idea...");
+
return element;
}
document.body.appendChild(component());This becomes a problem when the module is executed in a CommonJS context where this is equal to module.exports. In this case you can override this using the imports-loader:
webpack.config.js
import path from "node:path";
import webpack from "webpack";
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: './src/index.js',
output: {
filename: 'main.js',
path: path.resolve(__dirname, 'dist'),
},
+ module: {
+ rules: [
+ {
+ test: fileURLToPath(import.meta.resolve('./src/index.js')),
+ use: 'imports-loader?wrapper=window',
+ },
+ ],
+ },
plugins: [
new webpack.ProvidePlugin({
join: ['lodash', 'join'],
}),
],
};Global Exports
Let's say a library creates a global variable that it expects its consumers to use. We can add a small module to our setup to demonstrate this:
project
webpack-demo
├── package.json
├── package-lock.json
├── webpack.config.js
├── /dist
├── /src
│ ├── index.js
+ │ ├── globals.js
└── /node_modulessrc/globals.js
const file = "blah.txt";
const helpers = {
test() {
console.log("test something");
},
parse() {
console.log("parse something");
},
};Now, while you'd likely never do this in your own source code, you may encounter a dated library you'd like to use that contains similar code to what's shown above. In this case, we can use exports-loader, to export that global variable as a normal module export. For instance, in order to export file as file and helpers.parse as parse:
webpack.config.js
import path from "node:path";
import webpack from "webpack";
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: './src/index.js',
output: {
filename: 'main.js',
path: path.resolve(__dirname, 'dist'),
},
module: {
rules: [
{
test: fileURLToPath(import.meta.resolve('./src/index.js')),
use: 'imports-loader?wrapper=window',
},
+ {
+ test: fileURLToPath(import.meta.resolve('./src/globals.js')),
+ use:
+ 'exports-loader?type=commonjs&exports=file,multiple|helpers.parse|parse',
+ },
],
},
plugins: [
new webpack.ProvidePlugin({
join: ['lodash', 'join'],
}),
],
};Now from within our entry script (i.e. src/index.js), we could use const { file, parse } = require('./globals.js'); and all should work smoothly.
Loading Polyfills
Almost everything we've discussed thus far has been in relation to handling legacy packages. Let's move on to our second topic: polyfills.
There's a lot of ways to load polyfills. For example, to include the babel-polyfill we might:
npm install --save babel-polyfilland import it so as to include it in our main bundle:
src/index.js
+import 'babel-polyfill';
+
function component() {
const element = document.createElement('div');
element.innerHTML = join(['Hello', 'webpack'], ' ');
// Assume we are in the context of `window`
this.alert("Hmmm, this probably isn't a great idea...");
return element;
}
document.body.appendChild(component());Note that this approach prioritizes correctness over bundle size. To be safe and robust, polyfills/shims must run before all other code, and thus either need to load synchronously, or, all app code needs to load after all polyfills/shims load. There are many misconceptions in the community, as well, that modern browsers "don't need" polyfills, or that polyfills/shims merely serve to add missing features - in fact, they often repair broken implementations, even in the most modern of browsers. The best practice thus remains to unconditionally and synchronously load all polyfills/shims, despite the bundle size cost this incurs.
If you feel that you have mitigated these concerns and wish to incur the risk of brokenness, here's one way you might do it:
Let's move our import to a new file and add the whatwg-fetch polyfill:
npm install --save whatwg-fetchsrc/index.js
-import 'babel-polyfill';
-
function component() {
const element = document.createElement('div');
element.innerHTML = join(['Hello', 'webpack'], ' ');
// Assume we are in the context of `window`
this.alert("Hmmm, this probably isn't a great idea...");
return element;
}
document.body.appendChild(component());project
webpack-demo
├── package.json
├── package-lock.json
├── webpack.config.js
├── /dist
├── /src
│ ├── index.js
│ ├── globals.js
+ │ └── polyfills.js
└── /node_modulessrc/polyfills.js
import "babel-polyfill";
import "whatwg-fetch";webpack.config.js
import path from "node:path";
import webpack from "webpack";
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
- entry: './src/index.js',
+ entry: {
+ polyfills: './src/polyfills',
+ index: './src/index.js',
+ },
output: {
- filename: 'main.js',
+ filename: '[name].bundle.js',
path: path.resolve(__dirname, 'dist'),
},
module: {
rules: [
{
test: fileURLToPath(import.meta.resolve('./src/index.js')),
use: 'imports-loader?wrapper=window',
},
{
test: fileURLToPath(import.meta.resolve('./src/globals.js')),
use:
'exports-loader?type=commonjs&exports[]=file&exports[]=multiple|helpers.parse|parse',
},
],
},
plugins: [
new webpack.ProvidePlugin({
join: ['lodash', 'join'],
}),
],
};With that in place, we can add the logic to conditionally load our new polyfills.bundle.js file. How you make this decision depends on the technologies and browsers you need to support. We'll do some testing to determine whether our polyfills are needed:
dist/index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Getting Started</title>
+ <script>
+ const modernBrowser = 'fetch' in window && 'assign' in Object;
+
+ if (!modernBrowser) {
+ const scriptElement = document.createElement('script');
+
+ scriptElement.async = false;
+ scriptElement.src = '/polyfills.bundle.js';
+ document.head.appendChild(scriptElement);
+ }
+ </script>
</head>
<body>
- <script src="main.js"></script>
+ <script src="index.bundle.js"></script>
</body>
</html>Now we can fetch some data within our entry script:
src/index.js
function component() {
const element = document.createElement('div');
element.innerHTML = join(['Hello', 'webpack'], ' ');
// Assume we are in the context of `window`
this.alert("Hmmm, this probably isn't a great idea...");
return element;
}
document.body.appendChild(component());
+
+fetch('https://jsonplaceholder.typicode.com/users')
+ .then((response) => response.json())
+ .then((json) => {
+ console.log(
+ "We retrieved some data! AND we're confident it will work on a variety of browser distributions."
+ );
+ console.log(json);
+ })
+ .catch((error) =>
+ console.error('Something went wrong when fetching this data: ', error)
+ );If we run our build, another polyfills.bundle.js file will be emitted and everything should still run smoothly in the browser. Note that this set up could likely be improved upon but it should give you a good idea of how you can provide polyfills only to the users that actually need them.
Further Optimizations
The babel-preset-env package uses browserslist to transpile only what is not supported in your browsers matrix. This preset comes with the useBuiltIns option, false by default, which converts your global babel-polyfill import to a more granular feature by feature import pattern:
import "core-js/modules/es7.string.pad-start";
import "core-js/modules/es7.string.pad-end";
import "core-js/modules/web.timers";
import "core-js/modules/web.immediate";
import "core-js/modules/web.dom.iterable";See the babel-preset-env documentation for more information.
Serving a smaller polyfill to modern browsers
The same set of polyfills can be compiled twice and the right one picked at runtime, so that browsers which need little only download little. The entry decides which build to fetch, and both are loaded before the application:
src/index.js
(async () => {
if (isLegacyBrowser()) {
await import("./polyfills?legacy");
} else {
await import(/* webpackMode: "eager" */ "./polyfills");
}
await import(/* webpackMode: "eager" */ "./app");
})();src/polyfills.js
import "core-js";
import "regenerator-runtime/runtime";The two builds differ only in what Babel is told to target, which Rule.resourceQuery selects through the ?legacy query:
webpack.config.js
export default {
module: {
rules: [
{
test: /\.js$/,
include: [path.resolve(__dirname, "src")],
oneOf: [
{
resourceQuery: /legacy/,
loader: "babel-loader",
options: {
presets: [
[
"@babel/preset-env",
{ useBuiltIns: "entry", targets: "ie 11" },
],
],
},
},
{
loader: "babel-loader",
options: {
presets: [
[
"@babel/preset-env",
{ useBuiltIns: "entry", targets: "last 2 versions" },
],
],
},
},
],
},
],
},
};Node Built-Ins
Node built-ins like global, __dirname and __filename can be handled directly from your configuration file with the node option, without the use of any special loaders or plugins. process and Node core modules such as buffer are not polyfilled by webpack 5; provide them with ProvidePlugin or resolve.fallback. See the node configuration page for more information and examples.
Other Utilities
There are a few other tools that can help when dealing with legacy modules.
When there is no AMD/CommonJS version of the module and you want to include the dist, you can flag this module in noParse. This will cause webpack to include the module without parsing it or resolving import and require() statements. This practice is also used to improve the build performance.
Lastly, there are some modules that support multiple module styles; e.g. a combination of AMD, CommonJS, and legacy. In most of these cases, they first check for define and then use some quirky code to export properties. In these cases, it could help to force the CommonJS path by setting additionalCode=var%20define%20=%20false; via the imports-loader.
TypeScript
TypeScript is a typed superset of JavaScript that compiles to plain JavaScript. In this guide we will learn how to integrate TypeScript with webpack.
Basic Setup
First install the TypeScript compiler and loader by running:
npm install --save-dev typescript ts-loaderNow we'll modify the directory structure & the configuration files:
project
webpack-demo
├── package.json
├── package-lock.json
+ ├── tsconfig.json
- ├── webpack.config.js
+ ├── webpack.config.ts
├── /dist
│ ├── bundle.js
│ └── index.html
├── /src
- │ ├── index.js
+ │ └── index.ts
└── /node_modulestsconfig.json
Let's set up a configuration to support JSX and compile TypeScript down to ES5...
{
"compilerOptions": {
"outDir": "./dist/",
"noImplicitAny": true,
"module": "esnext",
"moduleResolution": "bundler",
"target": "esnext",
"jsx": "react-jsx",
"allowJs": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}See TypeScript's documentation to learn more about tsconfig.json configuration options.
To learn more about webpack configuration, see the configuration concepts.
Now let's configure webpack to handle TypeScript:
First, install the required dependencies:
npm install --save-dev ts-node @types/nodewebpack.config.ts
import path from "node:path";
import { fileURLToPath } from "url";
import webpack from "webpack";
// in case you run into any TypeScript error when configuring `devServer`
import "webpack-dev-server";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const config: webpack.Configuration = {
entry: "./src/index.ts",
module: {
rules: [
{
test: /\.tsx?$/,
use: "ts-loader",
exclude: /node_modules/,
},
],
},
resolve: {
extensions: [".tsx", ".ts", ".js"],
},
output: {
filename: "bundle.js",
path: path.resolve(__dirname, "dist"),
},
};
export default config;For further refrence on how to write configuration in Typescript file
This will direct webpack to enter through ./index.ts, load all .ts and .tsx files through the ts-loader, and output a bundle.js file in our current directory.
Next, we need to adjust how we import lodash in our ./index.ts. Since the lodash definitions don't include a default export, we'll need to update our import statement.
First, make sure to install the TypeScript definitions:
npm install --save-dev @types/lodashThen, update your import at the top of the file:
./index.ts
- import _ from 'lodash';
+ import * as _ from 'lodash';
function component() {
const element = document.createElement('div');
element.innerHTML = _.join(['Hello', 'webpack'], ' ');
return element;
}
document.body.appendChild(component());Ways to Use TypeScript in webpack.config.ts
There are 3 ways to use TypeScript in webpack.config.ts:
-
Using webpack with built-in Node.js type stripping feature (recommended):
webpack -c ./webpack.config.tsWill attempt to load the configuration using Node.js's built-in type-stripping, and then attempt to load the configuration file using
interpretandrechoir(in this case you need to installtsxorts-nodeor other tools). -
Using custom
--import/--requirefor Node.js:NODE_OPTIONS='--import=tsx --no-experimental-strip-types' webpack -c ./webpack.config.tsNODE_OPTIONS='--require=ts-node/register --no-experimental-strip-types' webpack -c ./webpack.config.tsThe
--no-experimental-strip-typesflag is required starting with Node.js version 22.7.0. -
Using built-in Node.js transform types feature for Node.js ≥ v22.7.0:
To enable the transformation of non erasable TypeScript syntax, which requires JavaScript code generation, such as enum declarations, parameter properties.
NODE_OPTIONS='--experimental-transform-types' webpack --disable-interpret -c ./webpack.config.ts
TypeScript Path Aliases
5.105.0+If you use compilerOptions.paths or compilerOptions.baseUrl in your tsconfig.json to create import aliases, starting with webpack 5.105, webpack can read these aliases directly via resolve.tsconfig. This replaces tsconfig-paths-webpack-plugin, which should no longer be used.
resolve.tsconfig accepts boolean | string | object:
webpack.config.ts
export default {
resolve: {
tsconfig: true, // automatically find tsconfig.json
},
};Pass a string to point at a specific file (useful in monorepos):
export default {
resolve: {
tsconfig: "./tsconfig.app.json",
},
};Pass an object to also resolve TypeScript project references:
export default {
resolve: {
tsconfig: {
configFile: "./tsconfig.json",
references: "auto", // inherit references from tsconfig, or pass an array of paths
},
},
};tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}With the above, @/components/Button resolves to src/components/Button without any additional plugins or duplicating aliases in resolve.alias.
Migrating from tsconfig-paths-webpack-plugin
If you're currently using tsconfig-paths-webpack-plugin, you can drop it in favor of the built-in resolve.tsconfig option:
- import TsconfigPathsPlugin from 'tsconfig-paths-webpack-plugin';
export default {
resolve: {
- plugins: [new TsconfigPathsPlugin()],
+ // Auto-find tsconfig.json in the project root
+ tsconfig: true,
+
+ // Or explicitly point to one
+ // tsconfig: './tsconfig.app.json'
},
};You can then remove the package from your project:
npm uninstall tsconfig-paths-webpack-pluginLoader
We use ts-loader in this guide as it makes enabling additional webpack features, such as importing other web assets, a bit easier.
Note that if you're already using babel-loader to transpile your code, you can use @babel/preset-typescript and let Babel handle both your JavaScript and TypeScript files instead of using an additional loader. Keep in mind that, contrary to ts-loader, the underlying @babel/plugin-transform-typescript plugin does not perform any type checking.
Source Maps
To learn more about source maps, see the development guide.
To enable source maps, we must configure TypeScript to output inline source maps to our compiled JavaScript files. The following line must be added to our TypeScript configuration:
tsconfig.json
{
"compilerOptions": {
"outDir": "./dist/",
+ "sourceMap": true,
"noImplicitAny": true,
"module": "esnext",
"moduleResolution": "bundler",
"target": "esnext",
"jsx": "react-jsx",
"allowJs": true,
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}Now we need to tell webpack to extract these source maps and include in our final bundle:
webpack.config.ts
import path from "node:path";
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: './src/index.ts',
+ devtool: 'inline-source-map',
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
],
},
resolve: {
extensions: [ '.tsx', '.ts', '.js' ],
},
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
},
};See the devtool documentation for more information.
Client types
It's possible to use webpack specific features in your TypeScript code, such as import.meta.webpack. And webpack provides types for them as well, add a TypeScript reference directive to declare it:
/// <reference types="webpack/module" />
console.log(import.meta.webpack); // without reference declared above, TypeScript will throw an errorTo enable the types for the whole project, add webpack/module to compilerOptions.types in tsconfig.json:
{
"compilerOptions": {
"types": [
+ "webpack/module"
]
}
}Using Third Party Libraries
When installing third party libraries from npm, it is important to remember to install the typing definition for that library.
For example, if we want to use lodash, we should run the following command to install its type definitions:
npm install --save-dev @types/lodashIf the npm package already includes its declaration typings in the package bundle, downloading the corresponding @types package is not needed. For more information see the TypeScript changelog blog.
Importing Other Assets
To use non-code assets with TypeScript, we need to defer the type for these imports. This requires a custom.d.ts file which signifies custom definitions for TypeScript in our project. Let's set up a declaration for .svg files:
custom.d.ts
declare module "*.svg" {
const content: any;
export default content;
}Here we declare a new module for SVGs by specifying any import that ends in .svg and defining the module's content as any. We could be more explicit about it being a url by defining the type as string. The same concept applies to other assets including CSS, SCSS, JSON and more.
Build Performance
See the Build Performance guide on build tooling.
Web Workers
As of webpack 5, you can use Web Workers without worker-loader.
Syntax
new Worker(new URL("./worker.js", import.meta.url));// or customize the chunk name with magic comments
// see https://webpack.js.org/api/module-methods/#magic-comments
new Worker(
/* webpackChunkName: "foo-worker" */ new URL("./worker.js", import.meta.url),
);The syntax was chosen to allow running code without bundler, it is also available in native ECMAScript modules in the browser.
Note that while the Worker API suggests that Worker constructor would accept a string representing the URL of the script, in webpack 5 you can only use URL instead.
When using new Worker(), webpack can resolve worker modules by export condition names defined in the package's exports field. This allows packages to provide worker-specific versions of modules automatically.
package.json (in a dependency package):
{
"name": "my-package",
"exports": {
".": {
"worker": "./index.worker.js",
"default": "./index.js"
}
}
}When you import this package inside a worker context:
// Inside a worker file
import { someFunction } from "my-package";Webpack will automatically resolve to index.worker.js when the module is used in a worker context, without requiring any additional configuration.
Example
src/index.js
const worker = new Worker(new URL("./deep-thought.js", import.meta.url));
worker.postMessage({
question:
"The Answer to the Ultimate Question of Life, The Universe, and Everything.",
});
worker.onmessage = ({ data: { answer } }) => {
console.log(answer);
};src/deep-thought.js
globalThis.onmessage = ({ data: { question } }) => {
self.postMessage({
answer: 42,
});
};Set a public path from a variable
When you set __webpack_public_path__ from a variable, and use publicPath equal to auto, worker chunks will get a separate runtime, and Webpack runtime will set publicPath to automatically calculated public path, that is probably is not what you expect.
To work around this issue, you need to set __webpack_public_path__ from within the worker code. Here is an example:
worker.js
globalThis.onmessage = ({ data: { publicPath, ...otherData } }) => {
if (publicPath) {
__webpack_public_path__ = publicPath;
}
// rest of the worker code
};app.js
const worker = new Worker(new URL("./worker.js", import.meta.url));
worker.postMessage({ publicPath: globalThis.__MY_GLOBAL_PUBLIC_PATH_VAR__ });When to use this:
This pattern is only required when a worker needs to load additional chunks and the asset base URL is determined at runtime (for example, when using a CDN or a multi-domain deployment).
Since workers run in an isolated global scope, the automatically detected public path may differ from the one used by the main thread. In such cases, the public path (__webpack_public_path__) must be explicitly passed to the worker and set inside the worker runtime.
Note: This is an advanced use case. If your worker does not load additional chunks or your assets are served from a static, same-origin path, you typically do not need to set
__webpack_public_path__manually.
Node.js
This section describes using Web Workers in a Node.js environment via the worker_threads module.
Similar syntax is supported in Node.js (>= 12.17.0):
import { Worker } from "node:worker_threads";
new Worker(new URL("./worker.js", import.meta.url));Note that this is only available in ESM. Worker in CommonJS syntax is not supported by either webpack or Node.js.
Progressive Web Application
Progressive Web Applications (or PWAs) are web apps that deliver an experience similar to native applications. There are many things that can contribute to that. Of these, the most significant is the ability for an app to be able to function when offline. This is achieved through the use of a web technology called Service Workers.
This section will focus on adding an offline experience to our app. We'll achieve this using a Google project called Workbox which provides tools that help make offline support for web apps easier to setup.
We Don't Work Offline Now
So far, we've been viewing the output by going directly to the local file system. Typically though, a real user accesses a web app over a network; their browser talking to a server which will serve up the required assets (e.g. .html, .js, and .css files).
So let's test what the current experience is like using a server with more basic features. Let's use the http-server package: npm install http-server --save-dev. We'll also amend the scripts section of our package.json to add in a start script:
package.json
{
...
"scripts": {
- "build": "webpack"
+ "build": "webpack",
+ "start": "http-server dist"
},
...
}Note: webpack DevServer writes in-memory by default. We'll need to enable devserverdevmiddleware.writeToDisk option in order for http-server to be able to serve files from ./dist directory.
If you haven't previously done so, run the command npm run build to build your project. Then run the command npm start. This should produce the following output:
> http-server dist
Starting up http-server, serving dist
Available on:
http://xx.x.x.x:8080
http://127.0.0.1:8080
http://xxx.xxx.x.x:8080
Hit CTRL-C to stop the serverIf you open your browser to http://localhost:8080 (i.e. http://127.0.0.1) you should see your webpack application being served from the dist directory. If you stop the server and refresh, the webpack application is no longer available.
This is what we aim to change. Once we reach the end of this module we should be able to stop the server, hit refresh and still see our application.
Adding Workbox
Let's add the Workbox webpack plugin and adjust the webpack.config.js file:
npm install workbox-webpack-plugin --save-devwebpack.config.js
import path from "node:path";
import { fileURLToPath } from 'node:url';
+ import WorkboxPlugin from "workbox-webpack-plugin";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: './src/index.html',
experiments: {
html: true,
},
+ plugins: [
+ new WorkboxPlugin.GenerateSW({
+ // these options encourage the ServiceWorkers to get in there fast
+ // and not allow any straggling "old" SWs to hang around
+ clientsClaim: true,
+ skipWaiting: true,
+ }),
+ ],
output: {
filename: '[name].bundle.js',
htmlFilename: '[name].html',
path: path.resolve(__dirname, 'dist'),
clean: true,
},
};The page keeps its own title, so retitle it there:
src/index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
- <title>Output Management</title>
+ <title>Progressive Web Application</title>
<script src="./print.js"></script>
</head>
<body>
<script src="./index.js"></script>
</body>
</html>With that in place, let's see what happens when we do an npm run build:
...
asset main1.bundle.js 545 kB [emitted] [big]
asset main.bundle.js 2.74 kB [emitted]
asset index.html 254 bytes [emitted]
asset precache-manifest.b5ca1c555e832d6fbf9462efd29d27eb.js 268 bytes [emitted]
asset service-worker.js 1 kB [emitted]
...As you can see, we now have 2 extra files being generated; service-worker.js and the more verbose precache-manifest.b5ca1c555e832d6fbf9462efd29d27eb.js. service-worker.js is the Service Worker file and precache-manifest.b5ca1c555e832d6fbf9462efd29d27eb.js is a file that service-worker.js requires so it can run. Your own generated files will likely be different; but you should have a service-worker.js file there.
So we're now at the happy point of having produced a Service Worker. What's next?
Registering Our Service Worker
Let's allow our Service Worker to come out and play by registering it. We'll do that by adding the registration code below:
index.js
import _ from 'lodash';
import printMe from './print.js';
+ if ('serviceWorker' in navigator) {
+ window.addEventListener('load', () => {
+ navigator.serviceWorker.register('/service-worker.js').then(registration => {
+ console.log('SW registered: ', registration);
+ }).catch(registrationError => {
+ console.log('SW registration failed: ', registrationError);
+ });
+ });
+ }Once more npm run build to build a version of the app including the registration code. Then serve it with npm start. Navigate to http://localhost:8080 and take a look at the console. Somewhere in there you should see:
SW registeredNow to test it. Stop your server and refresh your page. If your browser supports Service Workers then you should still be looking at your application. However, it has been served up by your Service Worker and not by the server.
Conclusion
You have built an offline app using the Workbox project. You've started the journey of turning your web app into a PWA. You may now want to think about taking things further. A good resource to help you with that can be found here.
Public Path
The publicPath configuration option can be quite useful in a variety of scenarios. It allows you to specify the base path for all the assets within your application.
Use Cases
There are a few use cases in real applications where this feature becomes especially neat. Essentially, every file emitted to your output.path directory will be referenced from the output.publicPath location. This includes child chunks (created via code splitting) and any other assets (e.g. images, fonts, etc.) that are a part of your dependency graph.
Environment Based
In development for example, we might have an assets/ folder that lives on the same level of our index page. This is fine, but what if we wanted to host all these static assets on a CDN in production?
To approach this problem you can easily use a good old environment variable. Let's say we have a variable ASSET_PATH:
import webpack from "webpack";
// Try the environment variable, otherwise use root
const ASSET_PATH = process.env.ASSET_PATH || "/";
export default {
output: {
publicPath: ASSET_PATH,
},
plugins: [
// This makes it possible for us to safely use env vars on our code
new webpack.DefinePlugin({
"process.env.ASSET_PATH": JSON.stringify(ASSET_PATH),
}),
],
};On The Fly
Another possible use case is to set the publicPath on the fly. Webpack exposes a global variable called __webpack_public_path__ that allows you to do that. In your application's entry point, you can do this:
__webpack_public_path__ = process.env.ASSET_PATH;That's all you need. Since we're already using the DefinePlugin on our
configuration, process.env.ASSET_PATH will always be defined so we can safely
do that.
// entry.js
import "./public-path";
import "./app";Automatic publicPath
There are chances that you don't know what the publicPath will be in advance, and webpack can handle it automatically for you by determining the public path from variables like import.meta.url, document.currentScript, script.src or self.location. What you need is to set output.publicPath to 'auto':
webpack.config.js
export default {
output: {
publicPath: "auto",
},
};Note that in cases where document.currentScript is not supported, e.g., IE browser, you will have to include a polyfill like currentScript Polyfill.
Integrations
Let's start by clearing up a common misconception. Webpack is a module bundler like Browserify or Brunch. It is not a task runner like Make, Grunt, or Gulp. Task runners handle automation of common development tasks such as linting, building, or testing your project. Compared to bundlers, task runners have a higher level focus. You can still benefit from their higher level tooling while leaving the problem of bundling to webpack.
Bundlers help you get your JavaScript and stylesheets ready for deployment, transforming them into a format that's suitable for the browser. For example, JavaScript can be minified or split into chunks and lazy-loaded to improve performance. Bundling is one of the most important challenges in web development, and solving it well can remove a lot of pain from the process.
The good news is that, while there is some overlap, task runners and bundlers can play well together if approached in the right way. This guide provides a high-level overview of how webpack can be integrated into some of the more popular task runners.
NPM Scripts
Often webpack users use npm scripts as their task runner. This is a good starting point. Cross-platform support can become a problem, but there are several workarounds for that. Many, if not most users, get by with npm scripts and various levels of webpack configuration and tooling.
So while webpack's core focus is bundling, there are a variety of extensions that can enable you to use it for jobs typical of a task runner. Integrating a separate tool adds complexity, so be sure to weigh the pros and cons before going forward.
Grunt
For those using Grunt, we recommend the grunt-webpack package. With grunt-webpack you can run webpack or webpack-dev-server as a task, get access to stats within template tags, split development and production configurations and more. Start by installing grunt-webpack as well as webpack itself if you haven't already:
npm install --save-dev grunt-webpack webpackThen register a configuration and load the task:
Gruntfile.js
const webpackConfig = require("./webpack.config.js");
module.exports = function (grunt) {
grunt.initConfig({
webpack: {
options: {
stats: !process.env.NODE_ENV || process.env.NODE_ENV === "development",
},
prod: webpackConfig,
dev: { watch: true, ...webpackConfig },
},
});
grunt.loadNpmTasks("grunt-webpack");
};For more information, please visit the repository.
Gulp
Gulp is also a fairly straightforward integration with the help of the webpack-stream package (a.k.a. gulp-webpack). In this case, it is unnecessary to install webpack separately as it is a direct dependency of webpack-stream:
npm install --save-dev webpack-streamYou can require('webpack-stream') instead of webpack and optionally pass it an configuration:
gulpfile.js
import gulp from "gulp";
import webpack from "webpack-stream";
gulp.task("default", () =>
gulp
.src("src/entry.js")
.pipe(
webpack({
// Any configuration options...
}),
)
.pipe(gulp.dest("dist/")),
);For more information, please visit the repository.
Mocha
The mocha-webpack utility can be used for a clean integration with Mocha. The repository offers more details on the pros and cons but essentially mocha-webpack is a simple wrapper that provides almost the same CLI as Mocha itself and provides various webpack functionality like an improved watch mode and improved path resolution. Here is a small example of how you would install it and use it to run a test suite (found within ./test):
npm install --save-dev webpack mocha mocha-webpack
mocha-webpack 'test/**/*.js'For more information, please visit the repository.
Karma
The karma-webpack package allows you to use webpack to pre-process files in Karma.
npm install --save-dev webpack karma karma-webpackkarma.conf.js
export default function (config) {
config.set({
frameworks: ["webpack"],
files: [
{ pattern: "test/*_test.js", watched: false },
{ pattern: "test/**/*_test.js", watched: false },
],
preprocessors: {
"test/*_test.js": ["webpack"],
"test/**/*_test.js": ["webpack"],
},
webpack: {
// Any custom webpack configuration...
},
plugins: ["karma-webpack"],
});
}For more information, please visit the repository.
Advanced entry
Multiple file types per entry
It is possible to provide different types of files when using an array of values for entry to achieve separate bundles for CSS and JavaScript (and other) files in applications that are not using import for styles in JavaScript (pre Single Page Applications or different reasons).
Let's make an example. We have a PHP application with two page types: home and account. The home page has different layout and non-sharable JavaScript with the rest of the application (account page). We want to output home.js and home.css from our application files for the home page and account.js and account.css for account page.
home.js
console.log("home page type");home.scss
// home page individual stylesaccount.js
console.log("account page type");account.scss
// account page individual stylesWebpack extracts CSS on its own, so sass-loader is the only loader needed — give the rule a CSS module type and webpack takes it from there.
webpack.config.js
export default {
mode: process.env.NODE_ENV,
entry: {
home: ["./home.js", "./home.scss"],
account: ["./account.js", "./account.scss"],
},
output: {
filename: "[name].js",
cssFilename: "[name].css",
},
module: {
rules: [
{
test: /\.scss$/,
use: ["sass-loader"],
type: "css/auto",
},
],
},
experiments: {
css: true,
},
};Running webpack with above configuration will output into ./dist as we did not specify different output path. ./dist directory will now contain four files:
- home.js
- home.css
- account.js
- account.css
Asset Modules
Asset Modules allow one to use asset files (fonts, icons, etc) without configuring additional loaders.
Prior to webpack 5 it was common to use:
raw-loaderto import a file as a stringurl-loaderto inline a file into the bundle as a data URIfile-loaderto emit a file into the output directory
Asset Modules types replace all of these loaders by adding 5 new module types:
asset/resourceemits a separate file and exports the URL. Previously achievable by usingfile-loader.asset/inlineexports a data URI of the asset. Previously achievable by usingurl-loader.asset/sourceexports the source code of the asset. Previously achievable by usingraw-loader.asset/bytesexports aUint8Arrayview of the asset.assetautomatically chooses between exporting a data URI and emitting a separate file. Previously achievable by usingurl-loaderwith asset size limit.
When using the old assets loaders (i.e. file-loader/url-loader/raw-loader) along with Asset Modules in webpack 5, you might want to stop Asset Modules from processing your assets again as that would result in asset duplication. This can be done by setting the asset's module type to 'javascript/auto'.
webpack.config.js
export default {
module: {
rules: [
{
test: /\.(png|jpg|gif)$/i,
use: [
{
loader: 'url-loader',
options: {
limit: 8192,
}
},
],
+ type: 'javascript/auto'
},
]
},
}To exclude assets that came from new URL calls from the asset loaders add dependency: { not: ['url'] } to the loader configuration.
webpack.config.js
export default {
module: {
rules: [
{
test: /\.(png|jpg|gif)$/i,
+ dependency: { not: ['url'] },
use: [
{
loader: 'url-loader',
options: {
limit: 8192,
},
},
],
},
],
}
}Public Path
By default, under the hood, the asset type does __webpack_public_path__ + import.meta. This means that setting the output.publicPath in your config will allow you to override the URL from which the asset loads.
On The Fly Override
If you set the __webpack_public_path__ in code, the way you need to achieve it so as not to break the asset loading logic is to make sure you run it as the first code in your app and not use a function to do so. An example of this would be having a file called publicPath.js with contents
__webpack_public_path__ = "https://cdn.url.com";And then in your webpack.config.js updating your entry field to look like
export default {
entry: ["./publicPath.js", "./App.js"],
};Alternatively, you can do the following in your App.js without modifying your webpack config. The only downside is you have to enforce ordering here and that can collide with some linting tools.
import "./publicPath.js";Resource type
webpack.config.js
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: './src/index.js',
output: {
filename: 'main.js',
path: path.resolve(__dirname, 'dist')
},
+ module: {
+ rules: [
+ {
+ test: /\.png/,
+ type: 'asset/resource',
+ },
+ ],
+ },
};src/index.js
import mainImage from "./images/main.png";
img.src = mainImage; // '/dist/151cfcfa1bd74779aadb.png'All .png files will be emitted to the output directory and their paths will be injected into the bundles, besides, you can customize outputPath and publicPath for them.
Custom output filename
By default, asset/resource modules are emitting with [hash][ext][query][fragment] filename into output directory.
You can modify this template by setting output.assetModuleFilename in your webpack configuration:
webpack.config.js
import path from "node:path";
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: './src/index.js',
output: {
filename: 'main.js',
path: path.resolve(__dirname, 'dist'),
+ assetModuleFilename: 'images/[hash][ext][query]',
},
module: {
rules: [
{
test: /\.png/,
type: 'asset/resource',
},
],
},
};Another case to customize output filename is to emit some kind of assets to a specified directory:
import path from "node:path";
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: './src/index.js',
output: {
filename: 'main.js',
path: path.resolve(__dirname, 'dist'),
+ assetModuleFilename: 'images/[hash][ext][query]',
},
module: {
rules: [
+ {
+ test: /\.html/,
+ type: 'asset/resource',
+ generator: {
+ filename: 'static/[hash][ext][query]',
+ },
+ },
],
},
};With this configuration all the html files will be emitted into a static directory within the output directory.
Rule.generator.filename is the same as output.assetModuleFilename and works only with asset and asset/resource module types.
Inlining assets
webpack.config.js
import path from "node:path";
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: './src/index.js',
output: {
filename: 'main.js',
path: path.resolve(__dirname, 'dist'),
},
module: {
rules: [
+ {
+ test: /\.svg/,
+ type: 'asset/inline',
+ },
],
},
};src/index.js
import metroMap from "./images/metro.svg";
block.style.background = `url(${metroMap})`; // url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDo...vc3ZnPgo=)All .svg files will be injected into the bundles as data URI.
Custom data URI generator
By default, data URI emitted by webpack represents file contents encoded by using Base64 algorithm.
If you want to use a custom encoding algorithm, you may specify a custom function to encode a file content:
webpack.config.js
import path from "node:path";
import { fileURLToPath } from 'node:url';
+ import svgToMiniDataURI from "mini-svg-data-uri";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: './src/index.js',
output: {
filename: 'main.js',
path: path.resolve(__dirname, 'dist')
},
module: {
rules: [
{
test: /\.svg/,
type: 'asset/inline',
+ generator: {
+ dataUrl: content => {
+ content = content.toString();
+ return svgToMiniDataURI(content);
+ },
+ },
},
],
},
};Now all .svg files will be encoded by mini-svg-data-uri package.
Source type
webpack.config.js
import path from "node:path";
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: './src/index.js',
output: {
filename: 'main.js',
path: path.resolve(__dirname, 'dist'),
},
module: {
rules: [
+ {
+ test: /\.txt/,
+ type: 'asset/source',
+ },
],
},
};src/example.txt
Hello worldsrc/index.js
import exampleText from "./example.txt";
block.textContent = exampleText; // 'Hello world';Alternative usage:
src/index.js
import exampleText from "./example.txt" with { type: "text" };
block.textContent = exampleText; // 'Hello world';All .txt files will be injected into the bundles as UTF-8 strings.
URL assets
When using new URL('./path/to/asset', import.meta.url), webpack creates an asset module too.
src/index.js
const logo = new URL("./logo.svg", import.meta.url);Depending on the target in your configuration, webpack would compile the above code into a different result:
// target: web
new URL(
`${__webpack_public_path__}logo.svg`,
document.baseURI || self.location.href,
);
// target: webworker
new URL(`${__webpack_public_path__}logo.svg`, self.location);
// target: node, node-webkit, nwjs, electron-main, electron-preload, async-node
new URL(
`${__webpack_public_path__}logo.svg`,
require("node:url").pathToFileURL(__filename),
);// any targets when ECMA modules output enabled
new URL(`${__webpack_public_path__}logo.svg`, import.meta.url);As of webpack 5.38.0, Data URLs are supported in new URL() as well:
src/index.js
const url = new URL("data:,", import.meta.url);
console.log(url.href === "data:,");
console.log(url.protocol === "data:");
console.log(url.pathname === ",");Asset type
webpack.config.js
import path from "node:path";
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: './src/index.js',
output: {
filename: 'main.js',
path: path.resolve(__dirname, 'dist')
},
module: {
rules: [
+ {
+ test: /\.txt/,
+ type: 'asset',
+ },
],
},
};Now webpack will automatically choose between resource and inline by following a default condition: a file with size less than 8kb will be treated as a inline module type and resource module type otherwise.
You can change this condition by setting a Rule.parser.dataUrlCondition.maxSize option on the module rule level of your webpack configuration:
webpack.config.js
import path from "node:path";
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: './src/index.js',
output: {
filename: 'main.js',
path: path.resolve(__dirname, 'dist'),
},
module: {
rules: [
{
test: /\.txt/,
type: 'asset',
+ parser: {
+ dataUrlCondition: {
+ maxSize: 4 * 1024, // 4kb
+ },
+ },
},
],
},
};Also you can specify a function to decide to inlining a module or not.
Bytes type
webpack.config.js
import path from "node:path";
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: './src/index.js',
output: {
filename: 'main.js',
path: path.resolve(__dirname, 'dist'),
},
module: {
rules: [
+ {
+ test: /\.txt/,
+ type: 'asset/bytes',
+ },
],
},
};src/example.txt
Hello worldsrc/index.js
import exampleText from "./example.txt";
const decoder = new TextDecoder("utf-8");
const textString = decoder.decode(exampleText);
block.textContent = textString; // 'Hello world';Alternative usage:
src/index.js
import exampleText from "./example.txt" with { type: "bytes" };
const decoder = new TextDecoder("utf-8");
const textString = decoder.decode(exampleText);
block.textContent = textString; // 'Hello world';All .txt files will be injected into the bundles as raw bytes (Uint8Array), without any text encoding or transformation.
Replacing Inline Loader Syntax
Before Asset Modules and Webpack 5, it was possible to use inline syntax with the legacy loaders mentioned above.
It is now recommended to remove all inline loader syntax and use a resourceQuery condition to mimic the functionality of the inline syntax.
For example, in the case of replacing raw-loader with asset/source type:
- import myModule from 'raw-loader!my-module';
+ import myModule from 'my-module?raw';and in the webpack configuration:
module: {
rules: [
// ...
+ {
+ resourceQuery: /raw/,
+ type: 'asset/source',
+ }
]
},and if you'd like to exclude raw assets from being processed by other loaders, use a negative condition:
module: {
rules: [
// ...
+ {
+ test: /\.m?js$/,
+ resourceQuery: { not: [/raw/] },
+ use: [ ... ]
+ },
{
resourceQuery: /raw/,
type: 'asset/source',
}
]
},or a oneOf list of rules. Here only the first matching rule will be applied:
module: {
rules: [
// ...
+ { oneOf: [
{
resourceQuery: /raw/,
type: 'asset/source',
},
+ {
+ test: /\.m?js$/,
+ use: [ ... ]
+ },
+ ] }
]
},Built-in query suffixes
5.109.0+When experiments.futureDefaults is enabled, webpack registers the rules above for you: the query suffixes ?raw, ?url, ?inline and ?no-inline work out of the box for any import, similar to Vite's asset queries.
import source from "./file.txt?raw"; // asset/source - the raw file content as a string
import dataUri from "./icon.svg?inline"; // asset/inline - a data: URI
import url from "./image.png?url"; // asset/resource - the emitted file URL
import fileUrl from "./small.png?no-inline"; // asset/resource - never inlined, even if a rule would inline itThe suffixes are matched anywhere in the query string (?foo&raw works too) as a oneOf list, so only the first matching suffix wins. Since they are plain default rules, your own module.rules take precedence and can override them.
Disable emitting assets
For use cases like Server side rendering, you might want to disable emitting assets, which is feasible with emit option under Rule.generator:
export default {
// …
module: {
rules: [
{
test: /\.png$/i,
type: "asset/resource",
generator: {
emit: false,
},
},
],
},
};Package exports
The exports field in the package.json of a package allows to declare
which module should be used when using module requests like import "package" or import "package/sub/path".
It replaces the default implementation that returns main field resp. index.js files for "package" and
the file system lookup for "package/sub/path".
When the exports field is specified, only these module requests are available.
Any other requests will lead to a ModuleNotFound Error.
General syntax
In general the exports field should contain an object
where each properties specifies a sub path of the module request.
For the examples above the following properties could be used:
"." for import "package" and "./sub/path" for import "package/sub/path".
Properties ending with a / will forward a request with this prefix to the old file system lookup algorithm.
For properties ending with *, * may take any value and any * in the property value is replaced with the taken value.
An example:
{
"exports": {
".": "./main.js",
"./sub/path": "./secondary.js",
"./prefix/": "./directory/",
"./prefix/deep/": "./other-directory/",
"./other-prefix/*": "./yet-another/*/*.js"
}
}| Module request | Result |
|---|---|
package | .../package/main.js |
package/sub/path | .../package/secondary.js |
package/prefix/some/file.js | .../package/directory/some/file.js |
package/prefix/deep/file.js | .../package/other-directory/file.js |
package/other-prefix/deep/file.js | .../package/yet-another/deep/file/deep/file.js |
package/main.js | Error |
Alternatives
Instead of providing a single result, the package author may provide a list of results. In such a scenario this list is tried in order and the first valid result will be used.
Note: Only the first valid result will be used, not all valid results.
Example:
{
"exports": {
"./things/": ["./good-things/", "./bad-things/"]
}
}Here package/things/apple might be found in .../package/good-things/apple or in .../package/bad-things/apple.
For example, given the following configuration:
{
"exports": {
".": ["-bad-specifier-", "./non-existent.js", "./existent.js"]
}
}Webpack 5.94.0+ will now throw an error since non-existent.js is not found while the previous behavior would have resolved to existent.js.
Conditional syntax
Instead of providing results directly in the exports field,
the package author may let the module system choose one based on conditions about the environment.
In this case an object mapping conditions to results should be used.
Conditions are tried in object order.
Once a condition matches, its target is used; if that target is null or cannot be resolved, resolution fails rather than trying later conditions.
Conditions might be nested to create a logical AND.
The last condition in the object might be the special "default" condition,
which is always matched.
Example:
{
"exports": {
".": {
"red": "./stop.js",
"yellow": "./stop.js",
"green": {
"free": "./drive.js",
"default": "./wait.js"
},
"default": "./drive-carefully.js"
}
}
}This translates to something like:
if (red) return "./stop.js";
if (yellow) return "./stop.js";
if (green) {
if (free) return "./drive.js";
return "./wait.js";
}
return "./drive-carefully.js";The available conditions vary depending on the module system and tool used.
Abbreviation
When only a single entry (".") into the package should be supported the { ".": ... } object nesting can be omitted:
{
"exports": "./index.mjs"
}{
"exports": {
"red": "./stop.js",
"green": "./drive.js"
}
}Notes about ordering
In an object where each key is a condition, order of properties is significant. Conditions are handled in the order they are specified.
Example: { "red": "./stop.js", "green": "./drive.js" } != { "green": "./drive.js", "red": "./stop.js" } (when both red and green conditions are set, first property will be used)
In an object where each key is a subpath, order of properties (subpaths) is not significant. More specific paths are preferred over less specific ones.
Example: { "./a/": "./x/", "./a/b/": "./y/", "./a/b/c": "./z" } == { "./a/b/c": "./z", "./a/b/": "./y/", "./a/": "./x/" } (order will always be: ./a/b/c > ./a/b/ > ./a/)
exports field is preferred over other package entry fields like main, module, browser or custom ones.
Support
| Feature | Supported by |
|---|---|
"." property | Node.js, webpack, rollup, esinstall, wmr |
| normal property | Node.js, webpack, rollup, esinstall, wmr |
property ending with / | |
property ending with * | Node.js, webpack, rollup, esinstall |
| Alternatives | Node.js, webpack, rollup, |
| Abbreviation only path | Node.js, webpack, rollup, esinstall, wmr |
| Abbreviation only conditions | Node.js, webpack, rollup, esinstall, wmr |
| Conditional syntax | Node.js, webpack, rollup, esinstall, wmr |
| Nested conditional syntax | Node.js, webpack, rollup, wmr(5) |
| Conditions Order | Node.js, webpack, rollup, wmr(6) |
"default" condition | Node.js, webpack, rollup, esinstall, wmr |
| Path Order | Node.js, webpack, rollup |
| Error when not mapped | Node.js, webpack, rollup, esinstall, wmr(7) |
| Error when mixing conditions and paths | Node.js, webpack, rollup |
(1) Removed in Node.js 17. Use * instead.
(2) "./" is intentionally ignored as key.
(3) The property value is ignored and property key is used as target. Effectively only allowing mappings with key and value are identical.
(4) The syntax is supported, but always the first entry is used, which makes it unusable for any practical use case.
(5) Fallback to alternative sibling parent conditions is handling incorrectly.
(6) For the require condition object order is handled incorrectly. This is intentionally as wmr doesn't differ between referencing syntax.
(7) When using "exports": "./file.js" abbreviation, any request e. g. package/not-existing will resolve to that. When not using the abbreviation, direct file access e. g. package/file.js will not lead to an error.
Conditions
Reference syntax
One of these conditions is set depending on the syntax used to reference the module:
| Condition | Description | Supported by |
|---|---|---|
import | Request is issued from ESM syntax or similar. | Node.js, webpack, rollup, esinstall(1), wmr(1) |
require | Request is issued from CommonJs/AMD syntax or similar. | Node.js, webpack, rollup, esinstall(1), wmr(1) |
style | Request is issued from a stylesheet reference. | webpack |
sass | Request is issued from a sass stylesheet reference. | - |
asset | Request is issued from a asset reference. | - |
script | Request is issued from a normal script tag without module system. | - |
These conditions might also be set additionally:
| Condition | Description | Supported by |
|---|---|---|
module | All module syntax that allows to reference javascript supports ESM. (only combined with import or require) | webpack, rollup, wmr |
esmodules | Always set by supported tools. | wmr |
types | Request is issued from typescript that is interested in type declarations. | - |
(1) import and require are both set independent of referencing syntax. require has always lower priority.
import
The following syntax will set the import condition:
- ESM
importdeclarations in ESM - JS
import()expression - HTML
<script type="module">in HTML - HTML
<link rel="preload/prefetch">in HTML - JS
new Worker(..., { type: "module" }) - WASM
importsection - ESM HMR (webpack)
import.meta.webpackHot.accept/decline([...]) - JS
Worklet.addModule - Using javascript as entrypoint
require
The following syntax will set the require condition:
- CommonJs
require(...) - AMD
define() - AMD
require([...]) - CommonJs
require.resolve() - CommonJs (webpack)
require.ensure([...]) - CommonJs (webpack)
require.context - CommonJs HMR (webpack)
module.hot.accept/decline([...]) - HTML
<script src="...">
style
The following syntax will set the style condition:
- CSS
@import - HTML
<link rel="stylesheet">
asset
The following syntax will set the asset condition:
- CSS
url() - ESM
new URL(..., import.meta.url) - HTML
<img src="...">
script
The following syntax will set the script condition:
- HTML
<script src="...">
script should only be set when no module system is supported.
When the script is preprocessed by a system supporting CommonJs
it should set require instead.
This condition should be used when looking for a javascript file that can be injected as script tag in a HTML page without additional preprocessing.
Optimizations
The following conditions are set for various optimizations:
| Condition | Description | Supported by |
|---|---|---|
production | In a production environment. No devtooling should be included. | webpack |
development | In a development environment. Devtooling should be included. | webpack |
Note: Since production and development is not supported by everyone, no assumption should be made when none of these is set.
Target environment
The following conditions are set depending on the target environment:
| Condition | Description | Supported by |
|---|---|---|
browser | Code will run in a browser. | webpack, esinstall, wmr |
electron | Code will run in electron.(1) | webpack |
worker | Code will run in a (Web)Worker.(1) | webpack |
worklet | Code will run in a Worklet.(1) | - |
node | Code will run in Node.js. | Node.js, webpack, wmr(2) |
deno | Code will run in Deno. | webpack |
bun | Code will run in Bun. | webpack |
react-native | Code will run in react-native. | - |
(1) electron, worker and worklet comes combined with either node or browser, depending on the context.
(2) This is set for browser target environment.
Since there are multiple versions of each environment the following guidelines apply:
node: Seeenginesfield for compatibility.browser: Compatible with current Spec and stage 4 proposals at time of publishing the package. Polyfilling resp. transpiling must be handled on consumer side.- Features that are not possible to polyfill or transpile should be used carefully as it limits the possible usage.
deno: TBDreact-native: TBD
Conditions: Preprocessor and runtimes
The following conditions are set depending on which tool preprocesses the source code.
| Condition | Description | Supported by |
|---|---|---|
webpack | Processed by webpack. | webpack |
Sadly there is no node-js condition for Node.js as runtime.
This would simplify creating exceptions for Node.js.
Conditions: Custom
The following tools support custom conditions:
| Tool | Supported | Notes |
|---|---|---|
| Node.js | yes | Use --conditions CLI argument. |
| webpack | yes | Use resolve.conditionNames configuration option. |
| rollup | yes | Use exportConditions option for @rollup/plugin-node-resolve |
| esinstall | no | - |
| wmr | no | - |
For custom conditions the following naming schema is recommended:
<company-name>:<condition-name>
Examples: example-corp:beta, google:internal.
Common patterns
All patterns are explained with a single "." entry into the package, but they can be extended from multiple entries too, by repeating the pattern for each entry.
These pattern should be used as guide not as strict ruleset. They can be adapted to the individual packages.
These pattern are based on the following list of goals/assumptions:
- Packages are rotting.
- We assume at some point packages are no longer being maintained, but they are continued to be used.
exportsshould be written to use fallbacks for unknown future cases.defaultcondition can be used for that.- As the future is unknown we assume an environment similar to browsers and module system similar to ESM.
- Not all conditions are supported by every tool.
- Fallbacks should be used to handled these cases.
- We assume the following fallback make sense in general:
- ESM > CommonJs
- Production > Development
- Browser > node.js
Depending on the package intention maybe something else makes sense and in this case the patterns should be adopted to that. Example: For a command line tool a browser-like future and fallback doesn't make a lot of sense, and in this case node.js-like environments and fallbacks should be used instead.
For complex use cases multiple patterns need to be combined by nesting these conditions.
Target environment independent packages
These patterns make sense for packages that do not use environment specific APIs.
Providing only an ESM version
{
"type": "module",
"exports": "./index.js"
}Note: Providing only a ESM comes with restrictions for node.js.
Such a package would only work in Node.js >= 14 and only when using import.
It won't work with require().
Providing CommonJs and ESM version (stateless)
{
"type": "module",
"exports": {
"node": {
"module": "./index.js",
"require": "./index.cjs"
},
"default": "./index.js"
}
}Most tools get the ESM version.
Node.js is an exception here.
It gets a CommonJs version when using require().
This will lead to two instances of these package when referencing it with require() and import, but that doesn't hurt as the package doesn't have state.
The module condition is used as optimization when preprocessing node-targeted code with a tool that supports ESM for require() (like a bundler, when bundling for Node.js).
For such a tool the exception is skipped.
This is technically optional, but bundlers would include the package source code twice otherwise.
You can also use the stateless pattern if you are able to isolate your package state in JSON files. JSON is consumable from CommonJs and ESM without polluting the graph with the other module system.
Note that here stateless also means class instances are not tested with instanceof as there can be two different classes because of the double module instantiation.
Providing CommonJs and ESM version (stateful)
{
"type": "module",
"exports": {
"node": {
"module": "./index.js",
"import": "./wrapper.js",
"require": "./index.cjs"
},
"default": "./index.js"
}
}// wrapper.js
import cjs from "./index.cjs";
export const A = cjs.A;
export const B = cjs.B;In a stateful package we must ensure that the package is never instantiated twice.
This isn't a problem for most tools, but Node.js is again an exception here. For Node.js we always use the CommonJs version and expose named exports in the ESM with a ESM wrapper.
We use the module condition as optimization again.
Providing only a CommonJs version
{
"type": "commonjs",
"exports": "./index.js"
}Providing "type": "commonjs" helps to statically detect CommonJs files.
Providing a bundled script version for direct browser consumption
{
"type": "module",
"exports": {
"script": "./dist-bundle.js",
"default": "./index.js"
}
}Note that despite using "type": "module" and .js for dist-bundle.js this file is not in ESM format.
It should use globals to allow direct consumption as script tag.
Providing devtools or production optimizations
These patterns make sense when a package contains two versions, one for development and one for production. E. g. the development version could include additional code for better error message or additional warnings.
Without Node.js runtime detection
{
"type": "module",
"exports": {
"development": "./index-with-devtools.js",
"default": "./index-optimized.js"
}
}When the development condition is supported we use the version enhanced for development.
Otherwise, in production or when mode is unknown, we use the optimized version.
With Node.js runtime detection
{
"type": "module",
"exports": {
"development": "./index-with-devtools.js",
"production": "./index-optimized.js",
"node": "./wrapper-process-env.cjs",
"default": "./index-optimized.js"
}
}wrapper-process-env.cjs
module.exports =
process.env.NODE_ENV !== "development"
? require("./index-optimized.cjs")
: require("./index-with-devtools.cjs");We prefer static detection of production/development mode via the production or development condition.
Node.js allows to detection production/development mode at runtime via process.env.NODE_ENV, so we use that as fallback in Node.js. Sync conditional importing ESM is not possible and we don't want to load the package twice, so we have to use CommonJs for the runtime detection.
When it's not possible to detect mode we fallback to the production version.
Providing different versions depending on target environment
A fallback environment should be chosen that makes sense for the package to support future environments. In general a browser-like environment should be assumed.
Providing Node.js, WebWorker and browser versions
{
"type": "module",
"exports": {
"node": "./index-node.js",
"worker": "./index-worker.js",
"default": "./index.js"
}
}Providing Node.js, browser and electron versions
{
"type": "module",
"exports": {
"electron": {
"node": "./index-electron-node.js",
"default": "./index-electron.js"
},
"node": "./index-node.js",
"default": "./index.js"
}
}Combining patterns
Example 1
This is an example for a package that has optimizations for production and development usage with runtime detection for process.env and also ships a CommonJs and ESM version
{
"type": "module",
"exports": {
"node": {
"development": {
"module": "./index-with-devtools.js",
"import": "./wrapper-with-devtools.js",
"require": "./index-with-devtools.cjs"
},
"production": {
"module": "./index-optimized.js",
"import": "./wrapper-optimized.js",
"require": "./index-optimized.cjs"
},
"default": "./wrapper-process-env.cjs"
},
"development": "./index-with-devtools.js",
"production": "./index-optimized.js",
"default": "./index-optimized.js"
}
}Example 2
This is an example for a package that supports Node.js, browser and electron, has optimizations for production and development usage with runtime detection for process.env and also ships a CommonJs and ESM version.
{
"type": "module",
"exports": {
"electron": {
"node": {
"development": {
"module": "./index-electron-node-with-devtools.js",
"import": "./wrapper-electron-node-with-devtools.js",
"require": "./index-electron-node-with-devtools.cjs"
},
"production": {
"module": "./index-electron-node-optimized.js",
"import": "./wrapper-electron-node-optimized.js",
"require": "./index-electron-node-optimized.cjs"
},
"default": "./wrapper-electron-node-process-env.cjs"
},
"development": "./index-electron-with-devtools.js",
"production": "./index-electron-optimized.js",
"default": "./index-electron-optimized.js"
},
"node": {
"development": {
"module": "./index-node-with-devtools.js",
"import": "./wrapper-node-with-devtools.js",
"require": "./index-node-with-devtools.cjs"
},
"production": {
"module": "./index-node-optimized.js",
"import": "./wrapper-node-optimized.js",
"require": "./index-node-optimized.cjs"
},
"default": "./wrapper-node-process-env.cjs"
},
"development": "./index-with-devtools.js",
"production": "./index-optimized.js",
"default": "./index-optimized.js"
}
}Looks complex, yes. We were already able to reduce some complexity due to a assumption we can make: Only node need a CommonJs version and can detect production/development with process.env.
Guidelines
- Avoid the
defaultexport. It's handled differently between tooling. Only use named exports. - Never provide different APIs or semantics for different conditions.
- Write your source code as ESM and transpile to CJS via babel, typescript or similar tools.
- Either use
.cjsortype: "commonjs"in package.json to clearly mark source code as CommonJs. This makes it statically detectable for tools if CommonJs or ESM is used. This is important for tools that only support ESM and no CommonJs. - ESM used in packages support the following types of requests:
- module requests are supported, pointing to other packages with a package.json.
- relative requests are supported, pointing to other files within the package.
- They must not point to files outside of the package.
data:url requests are supported.- other absolute or server-relative requests are not supported by default, but they might be supported by some tools or environments.
Modern Web Platform
This guide describes practical webpack patterns for Web Components, Import Maps, and Progressive Web Apps (PWAs) with Service Workers. Each section states the problem, shows a minimal configuration you can copy, and notes current limits relative to future webpack improvements.
Web Components with webpack
Problem
If more than one JavaScript bundle executes customElements.define() for the same tag name, the browser throws DOMException: Failed to execute 'define' on 'CustomElementRegistry'. That often happens when the module that registers an element is duplicated: separate entry points or async chunks each contain a copy of the registration code, so two bundles both run define for the same tag.
Approach
Use optimization.splitChunks so the module that defines the element lives in a single shared chunk loaded once. Adjust cacheGroups so your element definitions (or a dedicated folder such as src/elements/) are forced into one chunk. See Prevent Duplication for the general idea.
webpack.config.js
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: {
main: "./src/main.js",
admin: "./src/admin.js",
},
output: {
filename: "[name].js",
path: path.resolve(__dirname, "dist"),
clean: true,
},
optimization: {
splitChunks: {
chunks: "all",
cacheGroups: {
// Put shared custom element modules in one async chunk.
customElements: {
test: /[\\/]src[\\/]elements[\\/]/,
name: "custom-elements",
chunks: "all",
enforce: true,
},
},
},
},
};Ensure both entries import the same registration module (for example ./elements/my-element.js) so webpack can emit one custom-elements.js chunk instead of inlining duplicate registration in main and admin.
Limitations and future work
Splitting alone does not change browser rules: the tag name must still be registered exactly once per document. Webpack does not yet provide a first-class “register this custom element once” primitive beyond chunk graph control. Native support for deduplicating custom element registration across the build is planned; until then, rely on shared chunks and a single registration module.
Import Maps with webpack
Problem
Import maps let the browser resolve bare specifiers (import "lodash-es" from importmap.json or an inline <script type="importmap">). If webpack bundles those dependencies, you do not need an import map for them. If you want the browser to load a dependency from a URL (CDN or /vendor/) while your application code keeps bare imports, mark those modules as externals so webpack emits import statements that match your map.
Approach
Enable ES module output (experiments.outputModule and output.module), set externalsType: "module" for static imports, and list each bare specifier in externals with the same string the browser will resolve via the import map.
webpack.config.js
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
mode: "production",
experiments: {
outputModule: true,
},
entry: "./src/index.js",
externalsType: "module",
externals: {
"lodash-es": "lodash-es",
},
output: {
module: true,
filename: "[name].mjs",
path: path.resolve(__dirname, "dist"),
clean: true,
},
};importmap.json (served alongside your HTML; URLs must match your deployment)
Local vendor file:
{
"imports": {
"lodash-es": "/vendor/lodash-es.js"
}
}CDN (no self-hosting required):
{
"imports": {
"lodash-es": "https://cdn.jsdelivr.net/npm/lodash-es@4/+esm"
}
}The key "lodash-es" must match both the externals key and the specifier in your source (import … from "lodash-es"). The value is the URL the browser loads — either a local path or a CDN URL; webpack does not validate that file.
index.html (order matters: import map before your bundle)
<script type="importmap" src="/importmap.json"></script>
<script type="module" src="/dist/main.mjs"></script>Limitations and future work
Webpack does not emit or update importmap.json for you. You must maintain the map so specifiers and URLs stay aligned with externals and your server layout. Automatic import-map generation is not available in webpack 5 today; future tooling may reduce this manual step.
Progressive Web Apps (PWA) and Service Workers
Problem
Long-lived caching requires stable URLs for HTML but versioned URLs for scripts and styles. Using [contenthash] in output.filename changes those URLs every build. A service worker precache list must list the exact URLs after each build, or offline shells will point at missing files.
The workbox-webpack-plugin GenerateSW plugin generates an entire service worker for you. That is convenient, but when you need full control over service worker code (custom routing, skipWaiting behavior, or coordination with [contenthash] and other plugins), InjectManifest is appropriate: you write the worker, and Workbox injects the precache manifest at build time from webpack’s asset list.
Approach
Use [contenthash] for emitted assets and add InjectManifest from workbox-webpack-plugin. Your source template imports workbox-precaching and calls precacheAndRoute(self.__WB_MANIFEST); the plugin replaces self.__WB_MANIFEST with the list of webpack assets (including hashed filenames).
Install:
npm install workbox-webpack-plugin workbox-precaching --save-devwebpack.config.js
import path from "node:path";
import { fileURLToPath } from "node:url";
import { InjectManifest } from "workbox-webpack-plugin";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: "./src/index.js",
experiments: {
html: true,
},
output: {
filename: "[name].[contenthash].js",
htmlFilename: "index.html",
path: path.resolve(__dirname, "dist"),
clean: true,
html: {
title: "PWA + content hashes",
meta: {
charset: "utf8",
viewport: "width=device-width, initial-scale=1",
},
},
},
plugins: [
new InjectManifest({
swSrc: path.resolve(__dirname, "src/service-worker.js"),
swDest: "service-worker.js",
}),
],
};src/service-worker.js (precache template)
import { precacheAndRoute } from "workbox-precaching";
// Replaced at build time with webpack's precache manifest (hashed asset URLs).
precacheAndRoute(globalThis.__WB_MANIFEST);Register the emitted service-worker.js from your app (for example in src/index.js) with navigator.serviceWorker.register("/service-worker.js"), served from dist/ with the correct scope.
Limitations and future work
You must keep InjectManifest in sync with your output filenames and plugins; GenerateSW remains the simpler path when you do not need a custom worker. Webpack does not ship a built-in service worker precache generator; tighter integration with hashed assets may arrive in future releases. Until then, Workbox’s InjectManifest is a well-supported way to align [contenthash] output with precaching.
Native CSS
This guide shows how to use webpack's native CSS handling with experiments.css, and how to migrate an existing setup off css-loader, style-loader, and mini-css-extract-plugin.
Getting Started
Enable native CSS support in your webpack configuration:
webpack.config.js
export default {
experiments: {
css: true,
},
};With this option enabled, webpack understands .css files as first-class modules: it parses them itself instead of handing them to a loader.
What's built-in
"Built-in" means webpack does the work itself, with no loader in the chain — not that it covers everything the CSS loaders do. The scope is exactly this:
| Built in | Still needs a loader or plugin |
|---|---|
Parsing .css; resolving @import and url() / image-set() / src() / image() | Preprocessors — Sass, Less, Stylus and PostCSS always keep their loaders |
CSS Modules: composes, @value, :export, :local() / :global() | css-loader's importLoaders, localIdentRegExp, getJSON, and the url / import filter callbacks |
Extracting a .css file, or injecting a <style> tag at runtime | style-loader's insert, attributes, styleTagTransform, and its lazy / singleton injection modes |
| Content hashes, minification and browserslist vendor prefixes | Anything a PostCSS plugin does beyond @custom-media / @custom-selector |
@custom-media and @custom-selector | - |
| Hot Module Replacement for stylesheets | - |
So a project whose CSS setup is css-loader + style-loader (or mini-css-extract-plugin) with default options needs no CSS loader at all. A project that reaches for the options in the right-hand column keeps that loader for the files that need it — the two can coexist, rule by rule.
Importing CSS
After enabling the experiment, import .css files directly from JavaScript:
src/index.js
import "./styles.css";
const element = document.createElement("h1");
element.textContent = "Hello native CSS";
document.body.appendChild(element);src/styles.css
h1 {
color: #1f6feb;
}Webpack processes the CSS and includes it in the build output.
CSS module types
Native CSS introduces four Rule.type values. Knowing which one applies is the key to migrating, because each maps to a different css-loader modules.mode:
| Type | Scoping | css-loader equivalent |
|---|---|---|
css | Global, no CSS Modules parsing | modules: false |
css/global | Global selectors, but :local() is honored | modules.mode: 'global' |
css/module | Local by default, :global() escapes to global | modules.mode: 'local' |
css/auto | Picks css/module for *.module.css / *.modules.css, otherwise plain css (global, no CSS Modules parsing) | modules.auto: true |
The default rule webpack adds for /\.css$/i is css/auto, so *.module.css files become CSS Modules and everything else stays global — matching the most common css-loader configuration out of the box.
CSS Modules
With css/auto, name a file *.module.css (or *.modules.css) to opt it into CSS Modules:
src/button.module.css
.button {
background: #0d6efd;
color: white;
border: 0;
border-radius: 4px;
padding: 8px 12px;
}src/index.js
import * as styles from "./button.module.css";
const button = document.createElement("button");
button.className = styles.button;
button.textContent = "Click me";
document.body.appendChild(button);You can customize CSS Modules behavior with parser and generator options — see All options with examples below:
webpack.config.js
export default {
experiments: {
css: true,
},
module: {
parser: {
"css/auto": {
namedExports: true,
},
},
generator: {
"css/auto": {
exportsConvention: "camel-case-only",
localIdentName: "[uniqueName]-[id]-[local]",
},
},
},
};Supported CSS Modules features
Native CSS Modules understand the same authoring features as css-loader, so most stylesheets migrate unchanged:
composes— compose one local class from another (includingcomposes: foo from "./other.module.css"); the export resolves to the space-separated list of class names.@value— declare and import reusable values (@value primary: #1f6feb;,@value primary from "./vars.module.css").:export— expose arbitrary key/value pairs to JavaScript.:local()/:global()— switch scoping inline within any module type.
/* button.module.css */
@value brand: #1f6feb;
.base {
padding: 8px 12px;
}
.primary {
composes: base;
background: brand;
}
:export {
brandColor: brand;
}Output modes (exportType)
A single CSS module can be emitted in four ways. The exportType parser option selects which one, and each replaces a different piece of the classic toolchain:
exportType | Behavior | Replaces |
|---|---|---|
"link" (default) | Extracts a .css file, loaded via <link> | mini-css-extract-plugin |
"style" | Injects a <style> element from the runtime | style-loader |
"text" | Exports the CSS as a string | css-loader exportType: 'string' |
"css-style-sheet" | Exports a constructable CSSStyleSheet | css-loader exportType: 'css-style-sheet' |
Set it globally per module type:
export default {
experiments: { css: true },
module: {
parser: {
"css/auto": {
exportType: "style",
},
},
},
};or per rule for a subset of files:
export default {
experiments: { css: true },
module: {
rules: [
{
test: /\.css$/i,
type: "css/auto",
parser: { exportType: "style" },
},
],
},
};Custom media and custom selectors
Native CSS resolves @custom-media and @custom-selector at build time, so the two rules people most often reach for a PostCSS plugin for need no plugin:
src/styles.css
@custom-media --narrow (width <= 40rem);
@custom-selector :--heading h1, h2, h3;
@media (--narrow) {
:--heading {
font-size: 1.25rem;
}
}Resolution is file-local — a definition applies only to the file that declares it, not to the files it @imports nor to the whole project. Both are on by default and can be switched off individually with module.parser.css.customMedia and module.parser.css.customSelectors, which is what you want if a PostCSS plugin in your chain already handles them.
Minification
CSS assets are minified by the built-in minimizer whenever optimization.minimize is on — which it is by default in mode: 'production'. There is nothing to install and nothing to wire up:
webpack.config.js
export default {
mode: "production",
experiments: { css: true },
};That replaces css-minimizer-webpack-plugin and the cssnano chain behind it. Every transform that preserves what the stylesheet means is on by default — shortening colors, numbers, values and selectors, merging longhands and rules, dropping dead rules, folding calc() and other math over constants, normalizing quotes, and writing media queries in their range spelling. Two are off because they change text a script can read back (rewriteCustomProperties) or rarely pay for themselves once compressed (convertLengthUnits).
Configure them through the object form of optimization.minimize:
export default {
mode: "production",
experiments: { css: true },
optimization: {
minimize: {
css: {
comments: false,
// `getComputedStyle().getPropertyValue()` hands custom property text
// back as authored — opt in only if nothing reads it.
rewriteCustomProperties: true,
},
},
},
};Disable CSS minification alone with minimize: { css: false }, leaving JavaScript minification in place.
Vendor prefixes
The minimizer maintains vendor prefixes for your browserslist target: it adds the -webkit- / -moz- / -ms- spelling of a property, at-rule or pseudo-selector a selected browser still needs, and drops the ones none of them do. For most projects that is autoprefixer — and so postcss-loader — off the dependency list:
.browserslistrc
> 0.5%
last 2 versions
not deadwebpack.config.js
export default {
mode: "production",
target: "browserslist",
experiments: { css: true },
};Resource hints and font preloading
Assets referenced from CSS can be given <link rel="preload"> / prefetch hints without a plugin. module.parser.css.fontPreload seeds one preload for each @font-face reachable from an HTML entry's initial CSS — the primary src URL only, since preloading every format would download the font twice:
webpack.config.js
export default {
experiments: { css: true, html: true },
output: {
// A preload must match the font's CORS fetch.
crossOriginLoading: "anonymous",
},
module: {
parser: {
css: {
fontPreload: true,
},
},
},
};module.parser.css.urlHints gives per-asset rules for anything else reached through url(...), and output.resourceHints sets them project-wide:
export default {
experiments: { css: true, html: true },
module: {
parser: {
css: {
urlHints: [
{ test: /\.woff2$/, preload: true, as: "font" },
{ test: /hero\.avif$/, preload: true, as: "image" },
],
},
},
},
};CSS referenced from HTML
With experiments.html enabled, a <link rel="stylesheet"> in an HTML page becomes a CSS chunk entry and inline <style> bodies (and style="" attributes) run through this same CSS pipeline — @import and url() inside them resolve relative to the HTML file, and the minimizer options above apply to them too:
webpack.config.js
export default {
entry: "./src/index.html",
experiments: { css: true, html: true },
};See the Native HTML guide for the whole HTML story.
Migration Guide
Automated migration (codemod)
Most of the migration can be done for you by the @webpack/css-plugins-to-native-css codemod:
npx codemod @webpack/css-plugins-to-native-cssIt requires webpack 5.109.0 or later, since it relies on the experiments.css: "auto" default introduced in that release.
At a glance
| Legacy setup | Native equivalent |
|---|---|
mini-css-extract-plugin (MiniCssExtractPlugin.loader) | built-in extraction (default exportType: "link") |
MiniCssExtractPlugin filename / chunkFilename | output.cssFilename / output.cssChunkFilename |
style-loader | exportType: "style" |
css-loader | built-in CSS parsing (no loader needed) |
css-loader url / import | module.parser.css.url / import (both default true) |
css-loader modules (.module.css auto-detect) | css/auto module type |
css-loader modules.mode | css/module / css/global type + pure |
css-loader modules.localIdentName | generator localIdentName |
css-loader modules.exportLocalsConvention | generator exportsConvention |
css-loader modules.namedExport | module.parser.css.namedExports (default true) |
css-loader modules.exportOnlyLocals | generator exportsOnly |
css-loader esModule | generator esModule (default true) |
css-loader exportType: 'string' / 'css-style-sheet' | exportType: "text" / "css-style-sheet" |
css-minimizer-webpack-plugin / cssnano | optimization.minimize — on by default in production |
postcss-loader + autoprefixer | optimization.minimize.css.vendorPrefixes — on by default for a browserslist target |
postcss-custom-media / postcss-custom-selectors | built-in @custom-media / @custom-selector |
preload-webpack-plugin (fonts) | module.parser.css.fontPreload |
Migrate one loader at a time — the sections below go in the order that keeps the build green at each step.
1. Start from a classic setup
webpack.config.js
import MiniCssExtractPlugin from "mini-css-extract-plugin";
export default {
module: {
rules: [
{
test: /\.css$/i,
use: [MiniCssExtractPlugin.loader, "css-loader"],
},
],
},
plugins: [new MiniCssExtractPlugin()],
};2. Enable native CSS
webpack.config.js
export default {
experiments: {
css: true,
},
};The built-in /\.css$/i → css/auto rule now handles .css imports. Remove your custom rule and plugin once the following sections confirm each option has an equivalent.
3. Replace mini-css-extract-plugin
Native CSS extracts stylesheets and adds content hashes to them by default (exportType: "link"), so the plugin and its loader are no longer needed:
webpack.config.js
-import MiniCssExtractPlugin from "mini-css-extract-plugin";
-
export default {
+ experiments: {
+ css: true,
+ },
- module: {
- rules: [
- {
- test: /\.css$/i,
- use: [MiniCssExtractPlugin.loader, "css-loader"],
- },
- ],
- },
- plugins: [new MiniCssExtractPlugin()],
};Map the remaining plugin options:
mini-css-extract-plugin | Native equivalent |
|---|---|
filename | output.cssFilename |
chunkFilename | output.cssChunkFilename |
loader publicPath | output.publicPath |
loader esModule | generator esModule (default true) |
ignoreOrder | n/a — the order-conflict warning cannot be silenced per option; use ignoreWarnings, or CssModulesPlugin.getCompilationHooks(compilation).orderModules to define the order yourself |
webpack.config.js
export default {
experiments: { css: true },
output: {
cssFilename: "[name].[contenthash].css",
cssChunkFilename: "[id].[contenthash].css",
},
};4. Replace css-loader
Most css-loader options have a native counterpart under module.parser.css and module.generator.css. The common defaults (url, import, namedExports all on) already match a typical css-loader config, so many projects need no parser config at all.
css-loader option | Native equivalent |
|---|---|
url | module.parser.css.url — default true |
import | module.parser.css.import — default true |
importLoaders | n/a — loaders in the chain apply to @imported files automatically |
sourceMap | controlled by devtool (supports a per-type css entry) |
esModule | module.generator.css.esModule — default true |
exportType: 'string' | parser exportType: "text" |
exportType: 'css-style-sheet' | parser exportType: "css-style-sheet" |
modules (auto-detect) | css/auto module type (built-in) |
modules.mode: 'local' | css/module type |
modules.mode: 'global' | css/global type |
modules.mode: 'pure' | parser pure: true |
modules.localIdentName | generator localIdentName |
modules.exportLocalsConvention | generator exportsConvention |
modules.namedExport | parser namedExports — default true |
modules.exportOnlyLocals | generator exportsOnly |
modules.localIdentHashSalt | generator localIdentHashSalt |
modules.localIdentHashFunction | generator localIdentHashFunction |
For example, this css-loader CSS Modules config:
export default {
module: {
rules: [
{
test: /\.module\.css$/i,
use: [
{
loader: "css-loader",
options: {
modules: {
localIdentName: "[local]-[hash:base64:6]",
exportLocalsConvention: "camel-case-only",
namedExport: true,
},
},
},
],
},
],
},
};becomes:
webpack.config.js
export default {
experiments: { css: true },
module: {
parser: {
"css/auto": {
namedExports: true,
},
},
generator: {
"css/auto": {
localIdentName: "[local]-[hash:base64:6]",
exportsConvention: "camel-case-only",
},
},
},
};A few css-loader options work differently:
getLocalIdent— instead of a custom function, native CSS drives naming through thelocalIdentNametemplate, which also accepts a function.getJSON— the class-name mapping is exported by the CSS module itself and readable from the compilation's module graph, so a small plugin can serialize it to JSON when a framework needs the file on disk. For server-side rendering, you usually don't need it at all — see Server-side rendering.localIdentRegExpand filter-styleurl/importcallbacks have no native equivalent; keepcss-loaderfor the affected files, or exclude specific requests withIgnorePlugin.
5. Replace style-loader
If you used style-loader to inject styles at runtime instead of extracting a file, set exportType: "style":
webpack.config.js
export default {
experiments: { css: true },
module: {
parser: {
"css/auto": {
exportType: "style",
},
},
},
};This injects a <style> element from the webpack runtime, covering the default style-loader (injectType: "styleTag") use case. Scope it to a single rule if only some files should be injected while the rest are extracted:
export default {
experiments: { css: true },
module: {
rules: [
{
test: /\.inline\.css$/i,
type: "css/auto",
parser: { exportType: "style" },
},
],
},
};Notes on style-loader options: injectType: "linkTag" corresponds to the default exportType: "link" (extraction); attributes, insert, and styleTagTransform have no native equivalent — keep style-loader if you rely on them.
6. Keep using preprocessors (Sass, Less, PostCSS)
Native CSS replaces the CSS loaders, not preprocessor loaders. Keep the preprocessor loader in use and set the rule's type to css/auto so webpack treats the loader's output as CSS:
webpack.config.js
export default {
experiments: { css: true },
module: {
rules: [
{
test: /\.s[ac]ss$/i,
use: ["postcss-loader", "sass-loader"],
type: "css/auto",
},
],
},
};sass-loader compiles to CSS, postcss-loader post-processes it, and native CSS handles extraction, url(), and CSS Modules from there. The same pattern works for less-loader, stylus-loader, and friends.
7. Server-side rendering (node + web)
For SSR you typically build twice — a web bundle for the browser and a node bundle for the server — and the CSS Modules class names must match so the server-rendered markup hydrates cleanly on the client. This is what css-loader's getJSON was often used to round-trip; with native CSS you avoid the round-trip entirely by making localIdentName deterministic across targets.
Use a path-based template (no compilation-wide hash) so the same class name is produced on every target:
webpack.config.js
const common = {
experiments: { css: true },
module: {
rules: [
{
test: /\.module\.css$/i,
type: "css/module",
generator: {
// `[file]__[local]` is stable across targets — no getJSON sync needed.
localIdentName: "[file]__[local]",
},
},
],
},
};
export default [
{ ...common, name: "web", target: "web" },
{ ...common, name: "node", target: "node" },
];On a node target the CSS generator defaults to exportsOnly: true, so the server build exports only the class-name mapping and emits no stylesheet — exactly what an SSR renderer needs. The browser build still extracts the real CSS. If you prefer a single config, target: ["web", "node"] builds a universal bundle that runs in both environments.
8. Drop the CSS minimizer and autoprefixer
optimization.minimize covers both in production, so the minimizer plugin and — for most projects — the PostCSS chain that only ran autoprefixer can go:
webpack.config.js
-import CssMinimizerPlugin from "css-minimizer-webpack-plugin";
-
export default {
mode: "production",
+ target: "browserslist",
+ experiments: { css: true },
- optimization: {
- minimizer: ["...", new CssMinimizerPlugin()],
- },
};See Minification for what the built-in minimizer does and how to tune it.
9. Keep imports unchanged and validate
Your JS imports stay the same:
import "./styles.css";
import * as styles from "./button.module.css";Then check that:
- styles apply correctly in development,
- extracted
.cssfiles are emitted in production, - CSS Modules exports match your existing usage.
All options with examples
Configure options per module type under module.parser and module.generator. The keys are css, css/auto, css/global, and css/module; the examples below use css/auto since it backs the default rule.
Parser options
Boolean parser options default to true unless the table says otherwise.
| Option | Type | Default | Description |
|---|---|---|---|
import | boolean | true | Handle @import at-rules. |
url | boolean | true | Handle url() / image-set() / src() / image(). |
namedExports | boolean | true | Export CSS Modules locals as ES module named exports. |
exportType | "link" | "style" | "text" | "css-style-sheet" | "link" | How the CSS is emitted (see Output modes). |
pure | boolean | false | Strict pure mode — every selector must contain a local class/id. css/module and css/auto only. |
as | "stylesheet" | "block-contents" | "stylesheet" | Parse the source as a full stylesheet or as a block's contents. |
animation | boolean | true | Rename local @keyframes names. |
container | boolean | true | Rename local @container names. |
customIdents | boolean | true | Rename custom identifiers. |
dashedIdents | boolean | true | Rename dashed identifiers (custom properties). |
function | boolean | true | Rename local @function names. |
grid | boolean | true | Rename grid line/area identifiers. |
customMedia | boolean | true | Resolve @custom-media at build time. |
customSelectors | boolean | true | Resolve @custom-selector at build time. |
fontPreload | boolean | false | Preload each @font-face's primary src from an HTML entry. |
urlHints | UrlHintRule[] | — | Resource-hint rules for assets referenced via url(...). |
export default {
experiments: { css: true },
module: {
parser: {
"css/auto": {
import: true,
url: true,
namedExports: true,
exportType: "link",
pure: false,
// Only rename @keyframes; leave @container / grid identifiers untouched.
animation: true,
container: false,
grid: false,
},
},
},
};Generator options
| Option | Type | Default | Description |
|---|---|---|---|
localIdentName | string | function | "[uniqueName]-[id]-[local]" (dev) / "[fullhash]" (prod) | Template for generated local class names. |
exportsConvention | "as-is" | "camel-case" | "camel-case-only" | "dashes" | "dashes-only" | function | "as-is" | Naming convention for exported locals. |
exportsOnly | boolean | true on targets without a document (e.g. node), else false | Only export locals; skip emitting a stylesheet (SSR). |
esModule | boolean | true | Emit ES module syntax for the generated JS. |
localIdentHashFunction | string | output.hashFunction | Hash function for localIdentName hashes. |
localIdentHashDigest | string | "base64url" | Hash digest encoding for local idents. |
localIdentHashDigestLength | number | 6 | Hash digest length for local idents. |
localIdentHashSalt | string | output.hashSalt | Hash salt for local idents. |
export default {
experiments: { css: true },
module: {
generator: {
"css/auto": {
localIdentName: "[uniqueName]-[id]-[local]",
exportsConvention: "camel-case-only",
esModule: true,
exportsOnly: false,
localIdentHashDigest: "base64url",
localIdentHashDigestLength: 6,
},
},
},
};exportsConvention also accepts a function returning a string or string[] — returning an array exports the local under several aliases, matching css-loader's behavior.
Popular examples
CSS Modules with named exports
src/app.module.css
.primary {
color: #1f6feb;
}
.large-text {
font-size: 2rem;
}src/index.js
import { largeText, primary } from "./app.module.css";
document.body.classList.add(primary, largeText);webpack.config.js
export default {
experiments: { css: true },
module: {
generator: {
"css/auto": {
exportsConvention: "camel-case-only",
},
},
},
};Extract hashed CSS files for production
webpack.config.js
export default {
mode: "production",
experiments: { css: true },
output: {
cssFilename: "css/[name].[contenthash].css",
cssChunkFilename: "css/[id].[contenthash].css",
},
};Inject <style> tags at runtime (style-loader style)
webpack.config.js
export default {
experiments: { css: true },
module: {
parser: {
"css/auto": {
exportType: "style",
},
},
},
};Import a constructable stylesheet
src/index.js
import sheet from "./theme.css" with { type: "css" };
document.adoptedStyleSheets = [sheet];Webpack resolves the with { type: "css" } import assertion to exportType: "css-style-sheet" automatically, giving you a CSSStyleSheet instance.
Import CSS as a string
webpack.config.js
export default {
experiments: { css: true },
module: {
parser: {
"css/auto": {
exportType: "text",
},
},
},
};src/index.js
import css from "./styles.css";
const style = new CSSStyleSheet();
style.replaceSync(css);Global styles + scoped modules side by side
With the default css/auto rule, *.module.css is scoped and everything else is global — no extra config:
import "./reset.css"; // global
import * as card from "./card.module.css"; // scopedTailwind CSS (or any PostCSS chain)
Native CSS replaces the CSS loaders, not PostCSS. Keep postcss-loader and give the rule a CSS module type so webpack handles what comes out of it:
webpack.config.js
export default {
experiments: { css: true },
module: {
rules: [
{
test: /\.css$/i,
use: ["postcss-loader"],
type: "css/auto",
},
],
},
};Sass with CSS Modules
webpack.config.js
export default {
experiments: { css: true },
module: {
rules: [
{
test: /\.module\.s[ac]ss$/i,
use: ["sass-loader"],
type: "css/module",
},
{
test: /\.s[ac]ss$/i,
exclude: /\.module\.s[ac]ss$/i,
use: ["sass-loader"],
type: "css/global",
},
],
},
};Group every stylesheet into one file
By default CSS follows the chunk graph, so each entry gets its own stylesheet. A cache group keyed on the CSS module type collects them all into one instead:
webpack.config.js
export default {
mode: "production",
entry: { home: "./src/home.js", about: "./src/about.js" },
experiments: { css: true },
output: {
cssFilename: "css/[name].[contenthash].css",
},
optimization: {
splitChunks: {
cacheGroups: {
styles: {
type: "css/auto",
name: "styles",
chunks: "all",
enforce: true,
},
},
},
},
};Theme switching with constructable stylesheets
src/index.js
import dark from "./theme-dark.css" with { type: "css" };
import light from "./theme-light.css" with { type: "css" };
const media = matchMedia("(prefers-color-scheme: dark)");
const apply = () => {
document.adoptedStyleSheets = [media.matches ? dark : light];
};
media.addEventListener("change", apply);
apply();Each theme is a separate CSSStyleSheet, so swapping them costs no re-parse and no flash of unstyled content.
Development server with hot-reloaded styles
webpack.config.js
export default {
mode: "development",
experiments: { css: true },
devServer: {
hot: true,
},
};CSS changes are applied in place; there is no style-loader to configure and no separate HMR wiring.
Importing a package's stylesheet
/* src/styles.css */
@import "bootstrap";Webpack looks for a style field in the package's package.json and falls back to main, so bare specifiers work in CSS the way they do in JavaScript.
Experimental status & known limitations
experiments.css is explicitly experimental — treat it as opt-in and test carefully before a broad rollout.
- APIs and behavior may still evolve before webpack v6 defaults.
- A few loader options have no drop-in switch:
css-loader'slocalIdentRegExpand filter callbacks, andstyle-loader'sattributes/insert/styleTagTransform. Keep the loader for files that need them. (getLocalIdentmaps to thelocalIdentNamefunction form, andgetJSON/SSR is covered by matching class names across targets.) importLoadershas no equivalent — loaders in the chain apply to@imported files automatically.- If your project relies on advanced loader chains, validate each part before migrating fully.
Native HTML
This guide shows how to use webpack's native HTML handling with experiments.html, and how to migrate an existing setup off html-loader and html-webpack-plugin.
Getting Started
Enable native HTML support in your webpack configuration:
webpack.config.js
export default {
experiments: {
html: true,
},
};With this option enabled, webpack understands .html files as first-class modules: it parses them itself instead of handing them to a loader.
What's built-in
"Built-in" means webpack does the work itself, with no loader or plugin in the chain — not that it covers everything html-loader and html-webpack-plugin do. The scope is exactly this:
| Built in | Still needs a loader or plugin |
|---|---|
Parsing .html, as an entry point or imported from JS | Template engines — Pug, EJS, Handlebars and friends, unless a synchronous template hook is enough |
| Extracting every URL a page references and rewriting it to the built filename | html-loader's sources.scriptingEnabled and postprocessor |
Bundling inline <script> and <style> bodies, and style="" attributes | html-webpack-plugin's chunksSortMode, xhtml, showErrors and cache |
| Generating a page per entrypoint with its chunks injected | The third-party plugins that tap html-webpack-plugin's hooks — they do not run as-is, but most rewrite into a few lines |
title, meta, base, favicon, manifest, integrity, csp, inlining | - |
| Minification and Hot Module Replacement | - |
So a project using html-webpack-plugin to scaffold a document around its bundles, or html-loader to import a partial, needs neither. A project that renders its page through a template engine, or depends on a plugin that hooks into html-webpack-plugin, keeps that plugin.
Three ways to use HTML
Native HTML covers three separate jobs that used to need two different packages. Pick the one that matches your project — they can be combined.
| Use case | What you write | Replaces |
|---|---|---|
| HTML entry point — the page drives the build | entry: './src/index.html' | html-webpack-plugin with a template |
| Generated page — webpack scaffolds a document around your bundles | output.html: true | html-webpack-plugin with no template |
| HTML imported from JavaScript — a partial used as a string | import page from './page.html' | html-loader |
1. HTML as an entry point
Point entry at an .html file and webpack builds the page itself: every <script src>, <link rel="stylesheet">, <img src>, inline <style> and inline <script> becomes part of the module graph, and the emitted page has all of its URLs rewritten to the built (hashed) filenames.
src/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>My App</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<img src="./logo.png" alt="Logo" />
<script type="module" src="./index.js"></script>
</body>
</html>webpack.config.js
export default {
entry: "./src/index.html",
experiments: {
html: true,
css: true,
},
};This is the HTML-first model Vite and Parcel use: the page is the source of truth about which scripts and styles it needs, so there is no second list of entry points to keep in sync.
2. A generated page for a JavaScript entry
If you would rather keep a JavaScript entry and have webpack write the document for you, set output.html. Webpack generates one HTML file per non-HTML entrypoint and injects that entrypoint's initial JS and CSS chunks — including chunks shared through dependOn:
webpack.config.js
export default {
entry: {
main: "./src/main.js",
},
output: {
html: true,
},
experiments: {
html: true,
},
};This is the part of html-webpack-plugin that scaffolds a document around your bundles. Set output.html to an object to configure the generated page.
3. HTML imported from JavaScript
Importing an .html file from JavaScript gives you the processed HTML as a string, with every asset reference resolved through webpack — the role html-loader has played for years:
src/index.js
import page from "./page.html";
document.querySelector("#app").innerHTML = page;An HTML module imported this way is not emitted as a standalone file by default; an HTML module used as an entry is. Override either with module.generator.html.extract.
What webpack bundles from a page
By default the parser treats a known set of URL-bearing attributes as dependencies. Everything below is handled with no configuration:
| Markup | What happens |
|---|---|
<script src> | Becomes a classic chunk entry; the src is rewritten to the emitted chunk |
<script type="module" src> | Becomes an ES module chunk entry |
<script>…</script> (inline) | The body becomes its own entry; the tag is rewritten to <script src> |
<style>…</style> (inline) | Routed through the CSS pipeline; @import / url() resolved, text written back |
style="…" attribute | Routed through the CSS pipeline as a declaration list |
<link rel="stylesheet"> | Becomes a CSS chunk entry |
<link rel="modulepreload"> | Becomes an independent entry (preloaded, never executed by a sibling) |
<link rel="preload"> / <link rel="prefetch"> | Its script/style is bundled and the href rewritten |
<link rel="icon">, <link rel="manifest"> | Emitted as hashed assets; the manifest's icons / screenshots / shortcuts too |
<img src>, <img srcset>, <source srcset> | Emitted as hashed assets |
<iframe srcdoc> | The document inside is bundled through the HTML pipeline |
SVG references (fill, cursor, font-face-uri, …) | url(...) targets are emitted as assets |
<meta name="twitter:player:stream"> | Emitted as a hashed asset |
Two rules make the JavaScript side predictable:
- Multiple
<script src>tags on the same page share a single runtime. Within each group (classic ortype="module"), the leader holds the runtime and the rest declaredependOnon it. - With
output.moduleenabled, classic<script>tags are auto-upgraded totype="module"so the emitted ES module chunks load in the right mode.
Non-JS script types (application/ld+json, importmap, …), data URIs, and <style> with a non-CSS type flow through unchanged.
Skipping a single URL
Put a webpackIgnore comment immediately before a tag to leave its URLs alone — useful for CDN assets or URLs a server rewrites at runtime:
<!-- webpackIgnore: true -->
<script src="https://cdn.example.com/analytics.js"></script>Customizing which attributes are URLs
module.parser.html.sources controls the whole list. Use the literal "..." to keep the built-in defaults and add your own on top:
webpack.config.js
export default {
experiments: { html: true },
module: {
parser: {
html: {
sources: [
"...", // keep the built-in defaults
{ tag: "img", attribute: "data-src", type: "src" },
{ tag: "img", attribute: "data-srcset", type: "srcset" },
{ attribute: "data-href", type: "src" }, // any tag
{ tag: "img", attribute: "src", type: false }, // drop a built-in default
],
},
},
},
};Pass sources: false to switch URL extraction off entirely; inline <script> and <style> bodies are still processed. A filter callback skips individual elements:
export default {
experiments: { html: true },
module: {
parser: {
html: {
sources: [
"...",
{
tag: "img",
attribute: "src",
type: "src",
// Leave absolute URLs to the CDN alone.
filter: (attributes, value) => !value.startsWith("https://"),
},
],
},
},
},
};Linking pages together
The html source type makes an href to another page part of the build: the linked file is bundled as its own emitted page and the attribute is rewritten to its output filename.
webpack.config.js
export default {
entry: "./src/index.html",
experiments: { html: true },
module: {
parser: {
html: {
sources: ["...", { tag: "a", attribute: "href", type: "html" }],
},
},
},
};<!-- src/index.html -->
<a href="./about.html">About</a>Configuring the generated page
Every option below lives under output.html and applies to webpack-generated pages. They can be overridden per entry through the entry descriptor:
webpack.config.js
export default {
entry: {
app: "./src/app.js",
// This entry gets no HTML page.
worker: { import: "./src/worker.js", html: false },
// …and this one overrides a single option.
admin: { import: "./src/admin.js", html: { title: "Admin" } },
},
output: { html: { title: "My App" } },
experiments: { html: true },
};Title, meta and base
webpack.config.js
export default {
experiments: { html: true },
output: {
html: {
title: "My App",
meta: {
viewport: "width=device-width, initial-scale=1",
description: "A webpack-built app",
"og:title": "My App",
},
base: { href: "/app/", target: "_self" },
},
},
};The charset key is special-cased into a charset declaration (meta: { charset: "UTF-8" } emits <meta charset="UTF-8">), and keys beginning with og: use the property attribute instead of name. Each of these is skipped when the page already declares it, so an authored page always wins.
Where the tags go
output.html.inject places the injected chunk tags: 'body' (default; 'head' with output.module), 'head', or false to suppress sibling-chunk injection. output.html.scriptLoading chooses how they load: 'auto' (default — a module script for ES module output, defer otherwise), 'defer', or 'blocking'.
export default {
experiments: { html: true },
output: {
html: {
inject: "head",
scriptLoading: "defer",
},
},
};Stylesheet <link> tags always land in <head> when the page has one, ahead of the first blocking script.
Inlining critical chunks
output.html.inline writes a chunk's content straight into the page instead of linking it — the job html-webpack-inline-source-plugin used to do. The page's [contenthash] accounts for the inlined content.
export default {
experiments: { html: true },
output: {
html: {
// `true` inlines everything; `'script'` / `'style'` narrow it by type.
inline: [/^runtime/, /critical/],
},
},
};An authored page can opt a single reference in or out with the webpackInline magic comment:
<!-- webpackInline: true -->
<script src="./critical.js"></script>Favicons and the web app manifest
export default {
experiments: { html: true },
output: {
html: {
favicon: {
icon: [
{ href: "./favicon.svg", type: "image/svg+xml" },
{ href: "./favicon-32.png", sizes: "32x32" },
{
href: "./favicon-dark.png",
media: "(prefers-color-scheme: dark)",
},
],
"apple-touch-icon": "./apple-touch-icon.png",
},
manifest: {
name: "My App",
short_name: "App",
start_url: "/",
display: "standalone",
icons: [{ src: "./icon-512.png", sizes: "512x512" }],
},
},
},
};Every icon — including the ones named inside the manifest — is emitted as a hashed asset through the normal pipeline. Both options also accept a function receiving the page name, which is how you give each page of a multi-page build its own icon set.
Two limits are worth knowing. Webpack emits the icons you point it at; it does not generate the size and format variants a dedicated plugin such as favicons-webpack-plugin produces from one source image. And both options apply to webpack-generated pages only — an authored page is left exactly as written, so add the <link> tags to the page yourself when you own its markup.
Subresource Integrity and CSP
export default {
experiments: { html: true },
output: {
crossOriginLoading: "anonymous",
html: {
integrity: true, // or ['sha256', 'sha384']
csp: {
policy: {
"img-src": ["'self'", "data:"],
"connect-src": "https://api.example.com",
},
},
},
},
};integrity: true adds sha384 integrity attributes to injected <script> / <link> tags — pair it with output.crossOriginLoading, since SRI requires CORS-enabled fetches. csp injects a <meta http-equiv="Content-Security-Policy"> with a strict baseline plus a hash of every inline <script> / <style>; set nonce instead when your server rewrites one per request. Both replace webpack-subresource-integrity and csp-html-webpack-plugin.
Resource hints
output.resourceHints emits <link rel="preload"> / prefetch / modulepreload / preconnect tags into the page — the job preload-webpack-plugin used to do:
export default {
experiments: { html: true },
output: {
resourceHints: {
initial: true, // hint the initial dependency graph
preconnect: true, // preconnect to origins the build references
urlHints: [{ test: /\.woff2$/, preload: true, as: "font" }],
},
},
};An array gives you full control, including literal hrefs and references to a named chunk or entry:
export default {
experiments: { html: true },
output: {
resourceHints: [
{ rel: "preconnect", href: "https://cdn.example.com" },
{
rel: "preload",
href: "/fonts/inter.woff2",
as: "font",
type: "font/woff2",
crossorigin: true,
},
{ rel: "prefetch", entry: "settings" },
{ rel: "preload", chunk: "runtime", fetchPriority: "high" },
],
},
};For fonts referenced from CSS, module.parser.css.fontPreload seeds the hints automatically. For server-side rendering, resourceHints.manifest writes the resolved hint list to a JSON asset so the server can inject the tags itself.
Templating
module.parser.html.template transforms the raw HTML before the parser extracts dependencies, so URLs produced by a templating language are still discovered and bundled. It runs for authored pages and for generated ones alike.
webpack.config.js
export default {
experiments: { html: true },
module: {
parser: {
html: {
template: (source, { resource, addDependency }) => {
addDependency(resource);
return source
.replaceAll("{{title}}", "Hello world")
.replaceAll("{{image}}", "./image.png");
},
},
},
},
};The context object carries the current module and its resource, the build-dependency helpers (addDependency, addContextDependency, addMissingDependency, addBuildDependency) and emitWarning / emitError. Register the template files you read so watch mode picks up their changes.
Any synchronous template engine plugs in the same way:
import fs from "node:fs";
import { fileURLToPath } from "node:url";
import { compile } from "handlebars";
const dataFile = fileURLToPath(new URL("./src/data.json", import.meta.url));
export default {
experiments: { html: true },
module: {
parser: {
html: {
template: (source, { addDependency }) => {
// Rebuild the page when the data changes.
addDependency(dataFile);
const data = JSON.parse(fs.readFileSync(dataFile, "utf8"));
return compile(source)(data);
},
},
},
},
};Parsing a fragment
An HTML partial is not a document, and parsing it as one drops context-sensitive tags — a bare <tr> outside a table, for instance. module.parser.html.as names the context element to parse the source as:
export default {
experiments: { html: true },
module: {
rules: [
{
test: /\.rows\.html$/i,
type: "html",
parser: { as: "tbody" },
},
{
test: /\.partial\.html$/i,
type: "html",
parser: { as: "template" }, // a neutral fragment
},
],
},
};Minification
HTML assets are minified by the built-in minimizer whenever optimization.minimize is on — which it is by default in mode: 'production'. There is nothing to install and nothing to wire up:
webpack.config.js
export default {
mode: "production",
experiments: { html: true },
};Every transform that keeps the document's DOM is on by default; the ones that change what a script or a selector can read back are opt-in. Configure them through the object form of optimization.minimize:
export default {
mode: "production",
experiments: { html: true },
optimization: {
minimize: {
html: {
collapseWhitespace: "smart",
removeRedundantAttributes: "smart",
// Opt in — these change what the DOM reads back.
sortAttributes: true,
sortTokenLists: true,
mergeStyles: true,
},
},
},
};Inline <style> elements and style="" attributes are minified with the CSS minimizer using the options optimization.minimize.css names, so a stylesheet and the same declarations inline are treated identically. Disable HTML minification alone with minimize: { html: false }.
Hot Module Replacement
HTML modules support Hot Module Replacement with no extra configuration — it activates whenever HMR is on, for example via devServer.hot. For a page extracted to a real .html file, each update patches document.body.innerHTML and document.title in place; other <head> changes are patched in place as a delta, and only a removed <script> that already ran, or a reordering, falls back to a full reload.
Plugin hooks
webpack.html.HtmlModulesPlugin exposes injectTags, transformTags, transformHtml and htmlEmitted compilation hooks — the extension point that html-webpack-plugin's hooks provided. See HtmlModulesPlugin.getCompilationHooks.
class AddBuildStampPlugin {
apply(compiler) {
compiler.hooks.compilation.tap("AddBuildStamp", (compilation) => {
const hooks =
compiler.webpack.html.HtmlModulesPlugin.getCompilationHooks(
compilation,
);
hooks.transformHtml.tap("AddBuildStamp", (html) =>
html.replace("</body>", `<!-- built ${Date.now()} --></body>`),
);
});
}
}Migrating from html-loader
html-loader turned an imported .html file into a string with its URLs resolved. Native HTML does the same thing without a loader, so the migration is mostly deletion.
At a glance
html-loader option | Native equivalent |
|---|---|
sources | module.parser.html.sources — true by default |
sources.list | the array form of sources (use "..." to keep the defaults) |
sources.urlFilter | a filter callback on a source entry, or a webpackIgnore comment |
preprocessor | module.parser.html.template |
postprocessor | the transformHtml compilation hook |
minimize | optimization.minimize — on by default in production |
esModule | n/a — an HTML module exports the processed HTML as its default export |
Before
webpack.config.js
export default {
module: {
rules: [
{
test: /\.html$/i,
loader: "html-loader",
options: {
sources: {
list: ["...", { tag: "img", attribute: "data-src", type: "src" }],
},
minimize: true,
},
},
],
},
};After
webpack.config.js
export default {
experiments: {
html: true,
},
module: {
parser: {
html: {
sources: ["...", { tag: "img", attribute: "data-src", type: "src" }],
},
},
},
};Your imports do not change:
import page from "./page.html";Migrating from html-webpack-plugin
html-webpack-plugin did two jobs: scaffolding a document around your bundles, and rendering a template. Native HTML splits them — output.html scaffolds, and an HTML entry point or module.parser.html.template renders.
At a glance
html-webpack-plugin | Native equivalent |
|---|---|
no template | output.html: true |
template | use the file as an HTML entry point |
templateContent / templateParameters | module.parser.html.template |
filename | output.htmlFilename |
title | output.html.title |
meta | output.html.meta |
base | output.html.base |
inject | output.html.inject |
scriptLoading | output.html.scriptLoading |
favicon | output.html.favicon |
publicPath | output.publicPath |
minify | optimization.minimize |
hash | [contenthash] in output.filename |
chunks / excludeChunks | one page per entrypoint; opt an entry out with the entry descriptor html: false |
| several plugin instances (MPA) | several entries |
chunksSortMode | n/a — injection order follows the chunk graph |
cache, showErrors, xhtml | n/a |
| plugin hooks | HtmlModulesPlugin hooks |
Companion plugins fold in too. Most of the popular ones are an option now; the rest are a few lines against the HtmlModulesPlugin hooks:
| Plugin | Instead |
|---|---|
html-webpack-inline-source-plugin | output.html.inline, or a <!-- webpackInline: true --> comment on the tag |
webpack-subresource-integrity | output.html.integrity |
csp-html-webpack-plugin, strict-csp-html-webpack-plugin | output.html.csp |
preload-webpack-plugin, resource-hints-webpack-plugin, html-webpack-inject-preload | output.resourceHints, or the webpackPreload / webpackPrefetch comments |
favicons-webpack-plugin | output.html.favicon / manifest |
html-webpack-injector | output.html.inject, or put the tag where you want it in an HTML entry |
html-webpack-include-assets-plugin | write the tag in the page; mark files copied verbatim with <!-- webpackIgnore: true --> |
html-webpack-exclude-assets-plugin, html-webpack-skip-assets-plugin | nothing — a page carries what it references, and an entry opts out of a generated page with the descriptor html: false |
html-webpack-harddisk-plugin | devServer.devMiddleware.writeToDisk |
html-webpack-inject-attributes-plugin, webpack-nomodule-plugin, html-webpack-link-type-plugin | the transformTags hook |
inject-body-webpack-plugin, html-webpack-inline-style-plugin, html-webpack-inline-svg-plugin | write it in the page, or the transformHtml hook |
appcache-webpack-plugin | n/a — AppCache was removed from browsers |
Without a template
Before
import HtmlWebpackPlugin from "html-webpack-plugin";
export default {
entry: { main: "./src/main.js" },
plugins: [
new HtmlWebpackPlugin({
title: "My App",
scriptLoading: "defer",
favicon: "./src/favicon.png",
meta: { viewport: "width=device-width, initial-scale=1" },
}),
],
};After
export default {
entry: { main: "./src/main.js" },
experiments: { html: true },
output: {
html: {
title: "My App",
scriptLoading: "defer",
favicon: "./src/favicon.png",
meta: { viewport: "width=device-width, initial-scale=1" },
},
},
};With a template
A template that only listed your bundles becomes the entry itself — write the <script> and <link> tags you actually want, pointing at your source files, and webpack rewrites them to the built assets:
Before
import HtmlWebpackPlugin from "html-webpack-plugin";
export default {
entry: { main: "./src/main.js" },
plugins: [
new HtmlWebpackPlugin({
template: "./src/index.html",
filename: "index.html",
}),
],
};<!-- src/index.html -->
<!doctype html>
<html lang="en">
<head>
<title>My App</title>
</head>
<body>
<div id="root"></div>
<!-- html-webpack-plugin injected the script here -->
</body>
</html>After
export default {
entry: { index: "./src/index.html" },
experiments: { html: true, css: true },
};<!-- src/index.html -->
<!doctype html>
<html lang="en">
<head>
<title>My App</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.js"></script>
</body>
</html>The page now says what it loads, so the entry list and the template no longer have to agree.
Multi-page application
Instead of one plugin instance per page, use one entry per page:
Before
import HtmlWebpackPlugin from "html-webpack-plugin";
export default {
entry: { home: "./src/home.js", about: "./src/about.js" },
plugins: [
new HtmlWebpackPlugin({ filename: "home.html", chunks: ["home"] }),
new HtmlWebpackPlugin({ filename: "about.html", chunks: ["about"] }),
],
};After
export default {
entry: {
home: "./src/home.html",
about: "./src/about.html",
},
output: {
htmlFilename: "[name].html",
},
experiments: { html: true, css: true },
};Each page pulls in exactly the chunks it references, so chunks / excludeChunks have nothing left to do. The same works with generated pages — output.html: true plus one JavaScript entry per page.
Rewriting a plugin that tapped its hooks
A plugin written against html-webpack-plugin does not run without it, but the hook it used has a counterpart on HtmlModulesPlugin:
html-webpack-plugin | HtmlModulesPlugin |
|---|---|
beforeAssetTagGeneration | n/a — the tags come from the page and the chunk graph, so there is no asset list to edit up front |
alterAssetTags, alterAssetTagGroups | transformTags to change, remove or move existing tags, injectTags to add new ones |
afterTemplateExecution | module.parser.html.template |
beforeEmit | transformHtml |
afterEmit | htmlEmitted |
Both plugins hand you the hooks per compilation, so the shape of the plugin barely changes — swap HtmlWebpackPlugin.getCompilationHooks(compilation) for compiler.webpack.html.HtmlModulesPlugin.getCompilationHooks(compilation) and work with tag descriptors instead of the plugin's assetTags:
class NoModulePlugin {
apply(compiler) {
compiler.hooks.compilation.tap("NoModulePlugin", (compilation) => {
const hooks =
compiler.webpack.html.HtmlModulesPlugin.getCompilationHooks(
compilation,
);
// alterAssetTags: mutate what is already on the page
hooks.transformTags.tap("NoModulePlugin", (tags) => {
for (const tag of tags) {
if (tag.tag === "script") {
tag.attrs.nomodule = true;
}
}
});
// beforeAssetTagGeneration: add tags of your own
hooks.injectTags.tap("NoModulePlugin", (tags) => {
tags.push({
tag: "meta",
attrs: { name: "theme-color", content: "#2b3a42" },
});
return tags;
});
});
}
}transformTags mutates in place (attrs, remove: true, injectTo), while injectTags and transformHtml are waterfalls — return the value you were given. Anything that only rewrites the final markup, such as inlining styles or injecting a snippet into <body>, is a one-line transformHtml tap.
All options with examples
HTML is configured in four places: the parser and generator under module, the output options that describe the emitted page, and the minimizer options under optimization. The tables below list every option, with the reference entry linked from each name.
Parser options
Set them globally under module.parser.html, or per rule with parser on a rule whose type is html.
| Option | Type | Default | Description |
|---|---|---|---|
sources | boolean | Array<'...' | SourceEntry> | true | Which attribute values are treated as URLs and how each is bundled. |
as | 'document' | string | 'document' | Parse a full page, or the named element's inner HTML (a fragment). |
urlHints | UrlHintRule[] | [] | Default resource-hint rules for the assets this parser references. |
template | (source, context) => string | — | Transform the HTML source before dependencies are extracted. |
webpack.config.js
export default {
experiments: { html: true },
module: {
parser: {
html: {
sources: true,
as: "document",
urlHints: [{ test: /\.woff2$/, preload: true, as: "font" }],
template: (source) => source.replaceAll("{{title}}", "My App"),
},
},
},
};Source entries
Each entry in the sources array is either the string "..." (inline the built-in defaults) or an object:
| Field | Type | Required | Description |
|---|---|---|---|
attribute | string | yes | The attribute whose value is a URL. |
type | see source types, or false | yes | How the value is parsed and bundled; false drops a built-in. |
tag | string | no | Tag name to match. Omit to match any element. |
filter | (attributes: Map<string, string>, value: string) => boolean | no | Return false to skip this entry for a given element. |
An array without "..." opts out of the built-in list entirely. Inline <script> and <style> bodies are processed whatever sources says; only sources: false turns URL extraction off completely.
Source types
type | What the value is | How it is bundled |
|---|---|---|
src | one URL | Plain asset (<img src>) |
srcset | a srcset candidate list | Each URL as a plain asset |
css-url | a CSS value containing url(...) | Each reference as a plain asset (an SVG presentation attribute) |
script | a URL | Classic chunk entry, like <script src> |
script-module | a URL | ES module chunk entry, like <script type="module" src> |
stylesheet | a URL | CSS chunk entry, like <link rel="stylesheet"> |
html | a URL | Another page, bundled and emitted; the attribute gets its filename |
stylesheet-style | a full stylesheet | CSS pipeline; the processed CSS replaces the attribute's content |
stylesheet-style-attribute | a declaration list (like style="") | CSS pipeline, as a block's contents |
srcdoc | an entity-encoded HTML document | HTML pipeline; the processed HTML replaces the attribute's content |
false | — | Disables a built-in source for that tag / attribute pair |
webpack.config.js
export default {
experiments: { html: true, css: true },
module: {
parser: {
html: {
sources: [
"...",
{ tag: "img", attribute: "data-src", type: "src" },
{ tag: "img", attribute: "data-srcset", type: "srcset" },
{ tag: "a", attribute: "href", type: "html" },
{ tag: "my-widget", attribute: "styles", type: "stylesheet-style" },
{
tag: "my-widget",
attribute: "css",
type: "stylesheet-style-attribute",
},
{ tag: "template", attribute: "data-html", type: "srcdoc" },
{ tag: "circle", attribute: "fill", type: "css-url" },
{ attribute: "data-worker", type: "script-module" },
// Keep the defaults but stop treating `<img src>` as a URL.
{ tag: "img", attribute: "src", type: false },
],
},
},
},
};Generator options
| Option | Type | Default | Description |
|---|---|---|---|
extract | boolean | 'inline' | true for HTML entries, false when imported | Whether the processed HTML is emitted as a standalone .html output file. |
'inline' processes the HTML and hands it back for write-back into the document that holds it — what an <iframe srcdoc> needs — without emitting a file of its own.
webpack.config.js
export default {
experiments: { html: true },
module: {
rules: [
// A partial imported from JS: string only, no file.
{
test: /\.partial\.html$/i,
type: "html",
generator: { extract: false },
},
// A page imported from JS that should still be emitted.
{
test: /\.page\.html$/i,
type: "html",
generator: { extract: true },
},
],
},
};Output options
Everything under output.html describes the page webpack generates for a JavaScript entrypoint. Authored pages keep their own markup — of these, only inject, inline, integrity and csp affect what is injected into them; scriptLoading applies to generated pages only.
| Option | Type | Default | Description |
|---|---|---|---|
title | string | — | The page <title>. Skipped when the page has one. |
meta | object | — | <meta> tags; charset and og: keys are special-cased. |
base | string | { href, target } | — | A <base> element. Skipped when the page has one. |
inject | 'body' | 'head' | false | 'body' ('head' with output.module) | Where injected chunk tags go. |
scriptLoading | 'auto' | 'defer' | 'blocking' | 'auto' | How injected <script> tags load. |
inline | boolean | 'script' | 'style' | RegExp[] | false | Inline matching chunks into the page. |
favicon | boolean | string | object | function | false | Icon <link>s; every icon is emitted as a hashed asset. |
manifest | false | string | object | function | false | A web app manifest to link, or to serialize and emit. |
integrity | boolean | string[] | function | false | Subresource Integrity attributes on injected tags. |
csp | boolean | { policy, hashFunction, nonce } | false | A <meta http-equiv="Content-Security-Policy">. |
output.htmlFilename | string | function | output.filename with .html | Filename template for initial pages. |
output.htmlChunkFilename | string | function | output.chunkFilename with .html | Filename template for on-demand pages. |
webpack.config.js
export default {
entry: { main: "./src/main.js" },
experiments: { html: true, css: true },
output: {
htmlFilename: "[name].html",
htmlChunkFilename: "pages/[name].[contenthash].html",
crossOriginLoading: "anonymous",
html: {
title: "My App",
meta: { viewport: "width=device-width, initial-scale=1" },
base: { href: "/app/", target: "_self" },
inject: "head",
scriptLoading: "defer",
inline: [/^runtime/],
favicon: { icon: "./src/favicon.svg" },
manifest: { name: "My App", short_name: "App" },
integrity: ["sha384"],
csp: { policy: { "img-src": ["'self'", "data:"] } },
},
},
};The favicon, manifest and integrity options also take a function, which is how a multi-page build varies them per page or per asset:
export default {
experiments: { html: true },
output: {
html: {
favicon: (name) => `./src/icons/${name}.svg`,
manifest: (name) => (name === "app" ? "./src/app.webmanifest" : false),
// Skip SRI for the chunks a CDN rewrites.
integrity: ({ filename }) =>
filename.startsWith("vendor/") ? false : ["sha384"],
},
},
};Per-entry overrides use the entry descriptor html option, which takes the same values and merges over output.html option by option:
export default {
entry: {
app: "./src/app.js",
admin: { import: "./src/admin.js", html: { title: "Admin", csp: true } },
worker: { import: "./src/worker.js", html: false },
},
output: { html: { title: "My App" } },
experiments: { html: true },
};Resource-hint options
output.resourceHints accepts the initial value directly as a shorthand, or the full object:
| Option | Type | Default | Description |
|---|---|---|---|
initial | boolean | 'preload' | 'prefetch' | 'none' | HtmlResourceHint[] | function | true for ESM output, else false | Hints for the entry's initial dependency chunks. |
urlHints | UrlHintRule[] | [] | Project-wide rules for URL-referenced assets, applied to every parser. |
preconnect | boolean | false | Preconnect to a cross-origin output.publicPath origin. |
dedupe | boolean | false | Skip a runtime-injected prefetch for a chunk the document already hints. |
modulePreloadPolyfill | boolean | from output.environment.modulePreload | Inject the inline <link rel="modulepreload"> polyfill into extracted pages. |
manifest | string | — | Emit the resolved hints per entrypoint as a JSON asset at this path. |
'none' is the hard off switch — no <link> anywhere, and empty stats and manifest. false only disables the chunk hints, leaving urlHints and magic comments working.
Each descriptor in an initial array (and in output.resourceHints used as an array) is an HtmlResourceHint:
| Field | Type | Description |
|---|---|---|
rel | 'preload' | 'prefetch' | 'modulepreload' | 'preconnect' | 'dns-prefetch' | Required — the hint's rel. |
href | string | A literal URL, used verbatim. |
chunk | string | A chunk name; its emitted URL is resolved for you. |
entry | string | An entrypoint name; expands to one hint per initial chunk. |
as | string | The as attribute; defaults to script for chunk/entry references. |
type | string | The MIME type. |
media | string | The media attribute. |
crossorigin | boolean | 'anonymous' | 'use-credentials' | CORS mode; true means anonymous. |
fetchPriority | 'low' | 'high' | 'auto' | The fetchpriority attribute. |
integrity | boolean | Follows output.html.integrity; false opts this hint out. |
Exactly one of href / chunk / entry names the target; a descriptor that resolves to nothing is dropped silently.
A UrlHintRule — used by output.resourceHints.urlHints and by every parser's urlHints — matches assets by request and sets what a magic comment would:
| Field | Type | Description |
|---|---|---|
test / include / exclude | RuleSetCondition | Matched against the asset's request. Omit all three to match everything. |
preload / prefetch | boolean | Which hint to emit for matching assets. |
as, type, media | string | Attributes for the emitted <link>. |
fetchPriority | 'low' | 'high' | 'auto' | false | The fetchpriority attribute. |
webpack.config.js
export default {
experiments: { html: true, outputModule: true },
output: {
module: true,
resourceHints: {
initial: true,
preconnect: true,
dedupe: true,
modulePreloadPolyfill: false,
manifest: "resource-hints.json",
urlHints: [
{ test: /\.woff2$/, preload: true, as: "font", type: "font/woff2" },
{
include: /\/hero\//,
preload: true,
as: "image",
media: "(min-width: 800px)",
},
{
test: /\.png$/,
exclude: /\/hero\//,
prefetch: true,
fetchPriority: "low",
},
],
},
},
};An explicit magic comment always beats a rule, and a rule beats the defaults. The resolved list for each entrypoint is readable from stats as entrypoints[name].resourceHints, which is what the manifest asset serializes for a server-side renderer.
Minifier options
Every option of optimization.minimize.html, grouped by whether it is on by default. The ones that are off change what a script, a selector or a byte-for-byte comparison reads back, so they are opt-in.
On by default
| Option | Type | Default | Description |
|---|---|---|---|
collapseBooleanAttributes | boolean | 'all' | true | disabled="disabled" becomes the bare name; 'all' rewrites any value. |
comments | boolean | 'all' | 'some' | string | RegExp | function | 'some' | Which comments survive — 'some' keeps none, since HTML comments are inert. |
normalizeAttributeQuotes | boolean | true | Use whichever delimiters cost least. |
normalizeEnumeratedAttributes | boolean | true | Fold an enumerated value to the keyword it names. |
normalizeListAttributes | boolean | true | Normalize list-shaped values (class, rel, srcset, viewport content). |
normalizeNumericAttributes | boolean | true | Write an integer attribute the one way its rules read it. |
removeImpliedTags | boolean | 'smart' | 'all' | 'smart' | How much of the <html> / <head> / <body> shell may be implied. |
removeOptionalTags | boolean | true | Leave out other tags the parser can imply. |
Off by default
| Option | Type | Default | Why it is opt-in |
|---|---|---|---|
collapseWhitespace | boolean | 'conservative' | 'smart' | 'all' | false | textContent and a white-space: pre rule the minifier cannot see read the collapsed runs back; true means 'conservative'. |
mergeStyles | boolean | false | Removes elements, so document.styleSheets and style:nth-child() differ. |
minifyConditionalComments | boolean | false | The body is minified as if it started a document, not where the comment sits. |
removeEmptyAttributes | boolean | false | An attribute selector matches on presence, so [class] stops matching. |
removeEmptyElements | boolean | false | CSS the minifier cannot see may give an empty element a size or a ::before. |
removeRedundantAttributes | boolean | 'smart' | 'all' | false | Dropping an attribute changes getAttribute and attribute selectors. |
sortAttributes | boolean | false | A script reading element.attributes back sees the new order. |
sortTokenLists | boolean | false | A script reading className or rel back sees the new order. |
webpack.config.js
export default {
mode: "production",
experiments: { html: true, css: true },
optimization: {
minimize: {
html: {
collapseWhitespace: "smart",
comments: /^!/,
removeImpliedTags: "all",
removeRedundantAttributes: "smart",
removeEmptyAttributes: true,
removeEmptyElements: true,
mergeStyles: true,
sortAttributes: true,
sortTokenLists: true,
},
},
},
};Magic comments
An HTML comment placed immediately before a tag configures that tag alone:
| Comment | Effect |
|---|---|
<!-- webpackIgnore: true --> | Leave the tag's URLs untouched. |
<!-- webpackInline: true --> | Inline the referenced chunk into the page (false opts back out). |
<!-- webpackPreload: true --> | Set the next referenced asset's hint to preload. |
<!-- webpackPrefetch: true --> | Set it to prefetch. |
<!-- webpackFetchPriority: "high" --> | Set the hint's fetchpriority. |
The hint comments set the same fields a urlHints rule would and win over one. The resolved hints for each entrypoint are emitted as <link> tags in the extracted page's <head> and exposed through stats.entrypoints[name].resourceHints.
<!doctype html>
<html lang="en">
<head>
<!-- webpackPreload: true -->
<link rel="stylesheet" href="./critical.css" />
</head>
<body>
<!-- webpackIgnore: true -->
<script src="https://cdn.example.com/analytics.js"></script>
<!-- webpackInline: true -->
<script src="./bootstrap.js"></script>
<!-- webpackPrefetch: true -->
<!-- webpackFetchPriority: "low" -->
<img src="./below-the-fold.avif" alt="" />
</body>
</html>Popular examples
Single-page app with hashed assets
webpack.config.js
export default {
mode: "production",
entry: "./src/index.html",
output: {
filename: "js/[name].[contenthash].js",
cssFilename: "css/[name].[contenthash].css",
assetModuleFilename: "assets/[name].[contenthash][ext]",
htmlFilename: "[name].html",
clean: true,
},
experiments: { html: true, css: true },
};Inline the runtime chunk
webpack.config.js
export default {
mode: "production",
entry: { main: "./src/main.js" },
optimization: { runtimeChunk: "single" },
output: {
html: {
inline: [/^runtime/],
},
},
experiments: { html: true, css: true },
};Inlining the runtime removes one blocking request; inline: 'style' does the same for every CSS chunk.
A page per locale
webpack.config.js
import { fileURLToPath } from "node:url";
const locales = ["en", "de", "fr"];
const greetings = { en: "Hello", de: "Hallo", fr: "Bonjour" };
export default locales.map((locale) => ({
name: locale,
entry: { [locale]: "./src/index.html" },
output: {
path: fileURLToPath(new URL(`./dist/${locale}`, import.meta.url)),
htmlFilename: "index.html",
},
module: {
parser: {
html: {
template: (source) =>
source
.replaceAll("{{lang}}", locale)
.replaceAll("{{greeting}}", greetings[locale]),
},
},
},
experiments: { html: true, css: true },
}));Strict CSP with hashed inline scripts
webpack.config.js
export default {
mode: "production",
entry: "./src/index.html",
output: {
crossOriginLoading: "anonymous",
html: {
integrity: true,
csp: true,
},
},
experiments: { html: true, css: true },
};csp: true writes a strict baseline (script-src 'self', style-src 'self', object-src 'none', base-uri 'self') and appends a sha256 hash for each inline <script> / <style> so your own inline code keeps running.
An installable PWA
webpack.config.js
export default {
entry: { main: "./src/main.js" },
output: {
html: {
title: "My App",
meta: { viewport: "width=device-width, initial-scale=1" },
favicon: "./src/icon.svg",
manifest: {
name: "My App",
short_name: "App",
start_url: "/",
display: "standalone",
background_color: "#ffffff",
icons: [
{ src: "./src/icon-192.png", sizes: "192x192" },
{ src: "./src/icon-512.png", sizes: "512x512" },
],
},
},
},
experiments: { html: true },
};See Progressive Web Application for the service worker half.
HTML partials rendered at runtime
src/index.js
import card from "./card.partial.html";
document.querySelector("#list").insertAdjacentHTML("beforeend", card);webpack.config.js
export default {
experiments: { html: true },
module: {
rules: [
{
test: /\.partial\.html$/i,
type: "html",
parser: { as: "template" },
generator: { extract: false },
},
],
},
};Lazy-loaded page fragments
const template = await import("./modal.partial.html");
document.body.insertAdjacentHTML("beforeend", template.default);The fragment's own <img> and inline <style> are bundled and resolved just like a static import's, and only fetched when the dynamic import runs.
Development server
webpack.config.js
export default {
mode: "development",
entry: "./src/index.html",
devServer: {
hot: true,
},
experiments: { html: true, css: true },
};The emitted page is served from the build output, so there is no template to keep in sync with a static index.html, and edits to the page, its styles and its scripts are all hot-applied.
Limitations
experiments.html is explicitly opt-in — test it before a broad rollout.
- APIs and behavior may still evolve before the webpack v6 defaults.
- Full parity with
html-webpack-pluginis still in progress;chunksSortMode,xhtml,showErrorsandcachehave no equivalent, and template-engine loaders (pug-loader,ejs-loader, …) are replaced by the synchronoustemplatehook rather than being reused as-is. html-loader'ssources.scriptingEnabled(parsing<noscript>content as markup) has no native switch.- Module concatenation is disabled for HTML modules while HMR is active, because each module needs its own
module.hotscope.
Further reading
experiments.html— the flag and everything it unlocksoutput.html— every generated-page optionmodule.parser.html— parser optionsmodule.generator.html.extract— when a page is emitted- Native CSS — the CSS half of the same effort



