Webpack 5.108
Webpack 5.108 is out, and it pushes two big stories forward. The first continues the native HTML work that started in 5.107: a .html file can now be used directly as an entry, so webpack covers more of the role html-webpack-plugin has played for years, and HTML modules now support Hot Module Replacement. The second is a brand new universal target that compiles a single bundle that adapts at runtime to the browser, web workers, Node.js, Electron, and NW.js.
Around those headlines, this release lands a substantial round of tree-shaking improvements (a new optimization.inlineExports, cross-module purity, and CommonJS re-export analysis), modernizes the code webpack generates for capable targets, and adds a typed defineConfig helper.
Both the HTML and universal features are experimental and live behind opt-in flags, but the direction stays the same as 5.107: you should eventually be able to build a complete web app with zero extra loaders or plugins for HTML, CSS, and TypeScript.
Explore what's new:
- HTML Modules: Entry Points and HMR
- CSS Improvements
- The
universalTarget - Tree Shaking
- Output and Runtime
- Typed Configuration with
defineConfig - Bug Fixes
HTML Modules: Entry Points and HMR
HTML as an Entry Point
With experiments.html enabled, you can now point entry directly at an HTML file. Its <script src> and <link rel="stylesheet"> references go through webpack's pipelines, and the emitted HTML is rewritten to point at the generated JS and CSS chunks.
// webpack.config.js
module.exports = {
experiments: {
html: true,
css: true,
},
entry: {
page: "./page.html",
},
};To make this feel like Vite or Parcel, webpack 5.108 also adds .html (and .css, when experiments.css is enabled) to the default resolve.extensions, ahead of the JavaScript extensions. With the HTML experiment on, the default ./src entry therefore resolves ./src/index.html over ./src/index.js:
// webpack.config.js
module.exports = {
experiments: { html: true },
entry: "./src", // resolves to ./src/index.html
};These extensions are only added when the matching experiment is enabled, so default builds are unchanged.
Hot Module Replacement
HTML modules now support Hot Module Replacement. There's nothing to configure: it activates automatically when HMR is enabled, for example via devServer.hot.
For a page extracted to a real .html file, each hot update patches document.body.innerHTML and document.title in place instead of triggering a full reload. Changes to <head> beyond the <title> (a new <meta>, a swapped <link rel="icon">, and so on) can't be safely DOM-patched, so the shim falls back to a full page reload.
Customizing the HTML Parser
Two new options under module.parser.html give you control over how HTML modules are processed.
sources controls which URL-like attributes become webpack dependencies. Set it to false to disable extraction entirely, or pass an array to customize which tag/attribute pairs are treated as URLs. Use the literal string "..." to keep the built-in defaults:
// webpack.config.js
module.exports = {
experiments: { html: true },
module: {
parser: {
html: {
sources: [
"...", // keep the built-in defaults
{ tag: "img", attribute: "data-src", type: "src" },
{ attribute: "data-href", type: "src" }, // any tag
],
},
},
},
};template transforms the raw HTML source before the parser extracts dependencies, so URLs emitted by a templating language (Handlebars, EJS, Eta, …) are still discovered and bundled. It runs synchronously and must return the HTML string to parse:
// webpack.config.js
module.exports = {
experiments: { html: true },
module: {
parser: {
html: {
template: (source, { resource, addDependency }) => {
addDependency(resource);
return source
.replaceAll("{{title}}", "Hello world")
.replaceAll("{{image}}", "./image.png");
},
},
},
},
};CSS Improvements
url() Inside HTML style Attributes
Webpack can now route an HTML inline style="..." attribute through the CSS pipeline, resolving url(), image-set(), and @import relative to the HTML file. This is powered by a new module.parser.css.as option that selects the top-level CSS production to parse: "stylesheet" (the default, a full stylesheet) or "block-contents" (a declaration list, like the inside of a style attribute).
For the common HTML case you don't need to set it manually. Just enable both experiments and url() inside style attributes resolves automatically:
// webpack.config.js
module.exports = {
experiments: { html: true, css: true },
};CSS in Node for Universal Builds
For the new universal target (below), CSS now runs in Node without crashing and exposes styles for server-side rendering. Styles from style injection and link-loaded chunks are collected into a registry an SSR host can read at globalThis["__webpack_css__" + output.uniqueName]. Single-platform (web-only or node-only) builds emit no extra runtime, so this is scoped entirely to universal output.
The universal Target
Building code that runs in more than one environment used to mean hand-writing target: ["web", "node"] and dealing with the rough edges yourself. Webpack 5.108 adds a dedicated target: "universal" preset that combines the web, web worker, node, electron, and nwjs platforms, leaving each platform flag neutral so the bundle adapts at runtime instead of being locked to one environment.
// webpack.config.js
module.exports = {
target: "universal",
};Universal builds always output ECMAScript modules, so experiments.outputModule defaults to true for this target. Several pieces were made platform-aware so a single bundle behaves correctly everywhere:
new Worker(new URL(...))resolves theWorkerconstructor fromworker_threadsin Node and from the globalWorkeron the web.commonjsandnode-commonjsexternals are supported in the ESM output (loaded defensively viacreateRequirefromprocess.getBuiltinModule, guarded so they never break the browser), andglobalexternals useglobalThisas the global object.- CSS is collected for SSR as described above.
Tree Shaking
optimization.inlineExports
A new optimization.inlineExports option (on by default in production) inlines ESM exports that bind to small primitive constants (a null, undefined, boolean, number, or string of at most 6 bytes) at every import site, replacing the imported binding with the literal value.
// flags.js
export const DEBUG = false;// app.js
import { DEBUG } from "./flags.js";
if (DEBUG) doSomething();Every reference to DEBUG is replaced with false. Once no import references it anymore, the export is left unused and dead-code elimination drops it; if flags.js has no side effects, the whole module is removed too, and the consuming code can collapse the now-constant branch.
Cross-Module Dead-Branch Skipping
Building on inline exports, webpack can now skip dependencies that live only in a provably-dead branch gated by an inlined imported constant. When the guarding condition can be evaluated at build time, the import specifiers, require() calls, and dynamic import() calls in the dead branch are skipped, so the unreachable modules are never bundled.
// app.js
import { devOnly } from "./dev-tools";
import { isDEV } from "./env"; // export const isDEV = false
import { prodOnly } from "./prod-tools";
export const tools = isDEV ? devOnly : prodOnly;Because isDEV inlines to false, the devOnly branch is dead and ./dev-tools is never bundled. Supported guard forms include ternaries, if statements, and the &&, ||, ??, and ! operators, including nested combinations.
Cross-Module Purity
In 5.107, the #__NO_SIDE_EFFECTS__ annotation only took effect within the module where it was declared. Webpack 5.108 propagates it across module boundaries, so an unused call in an importing module is tree-shaken too.
// pure.js
/*#__NO_SIDE_EFFECTS__*/
export function createThing(x) {
return { x };
}// app.js
import { createThing } from "./pure";
const unused = createThing(1); // dropped: result is never usedWhen you can't edit the source — for example, a function coming from a dependency — the new module.parser.javascript.pureFunctions option marks names as side-effect-free from your config instead:
// webpack.config.js
module.exports = {
module: {
rules: [
{
test: /pure-source\.js$/,
parser: {
pureFunctions: ["createSelector", "styled"],
},
},
],
},
};CommonJS Re-exports via Object.defineProperty
Webpack already analyzes CommonJS re-exports such as exports.foo = require("./foo") for tree shaking. Webpack 5.108 extends that analysis to re-exports defined with Object.defineProperty descriptors, including the lazy getter form used by barrel files:
// barrel.js
Object.defineProperty(exports, "foo", {
enumerable: true,
get: () => require("./foo"),
});These re-exports are now treated as structured re-exports, so they participate in exports analysis and tree shaking. Lazy getters keep their deferred semantics in the generated code, and a { get, set } descriptor keeps its setter.
Output and Runtime
Modern Syntax in Generated Code
Webpack tailors the syntax of its generated runtime code to the output.environment capabilities inferred from your target. This release adds several capability flags so modern targets get smaller, cleaner runtime code, while older targets keep their exact previous output:
let— emitlet/constinstead ofvarwhere it's safe.spread— use{ ...obj }andfn(...args)instead ofObject.assign/concat.hasOwn— useObject.hasOwninstead ofObject.prototype.hasOwnProperty.call.symbol— drop thetypeof Symbol !== "undefined"guard in the namespace helper.nodeBuiltinModuleGetter— useprocess.getBuiltinModule()to load Node.js core modules (used by universal builds).
These are normally derived from your target; you only set them by hand to override that detection.
output.strictModuleResolution
The runtime guard that throws MODULE_NOT_FOUND when a required module id is missing from the bundle now has its own dedicated option, output.strictModuleResolution. It defaults to true in development and false in production. Previously this guard was tied to output.pathinfo; decoupling it lets you, for example, re-enable the check in a production build while debugging.
// webpack.config.js
module.exports = {
mode: "production",
output: {
strictModuleResolution: true,
},
};[uniqueName] Template Placeholder
A new [uniqueName] placeholder (with a [uniquename] alias) is available in template paths and resolves to output.uniqueName. It works in output.filename, output.chunkFilename, CSS localIdentName, and the other asset-path templates:
// webpack.config.js
module.exports = {
output: {
uniqueName: "my-app",
filename: "[uniqueName].[name].js", // -> my-app.main.js
},
};Worker Chunk Filenames
You can now name worker chunks independently of regular chunks with output.workerChunkFilename, which accepts the same string templates and function form as output.chunkFilename and defaults to its value. Entries that webpack creates from new Worker(new URL(...)) are marked as workers (via a new entry-level worker flag) so their output files use it.
// webpack.config.js
module.exports = {
output: {
chunkFilename: "[name].chunk.js",
workerChunkFilename: "workers/[name].[contenthash].worker.js",
},
};Typed Configuration with defineConfig
Webpack now exports a defineConfig helper that gives editors type-checking and autocomplete for your configuration without any extra type annotations. It's an identity function (a no-op at runtime that returns the config you pass in), so it works in plain JavaScript configs too, mirroring Vite, Rollup, and Rspack.
// webpack.config.js
const { defineConfig } = require("webpack");
module.exports = defineConfig({
mode: "none",
});It accepts every shape webpack-cli can load: a single configuration object, an array of configurations, a function returning either, an array of such functions, or a Promise resolving to any of them.
Bug Fixes
Several bug fixes have been resolved since version 5.107, along with a large batch of performance improvements across the CSS pipeline, the experimental HTML parser, module concatenation, and the persistent cache. Check the changelog for all the details.
Thanks
A big thank you to all our contributors and sponsors who made Webpack 5.108 possible. Your support, whether through code contributions, documentation, or financial sponsorship, helps keep Webpack evolving and improving for everyone.



