Understanding webpack: Entry, Loaders, Plugins, Module Federation and When You Still Need It in 2026
webpack treats every file as a module; loaders transform files right to left and plugins hook into the compilation. 5.112 supports CSS, HTML entries and TypeScript without loaders; Vite has replaced it in new projects, but it is still needed for old Angular builders, Module Federation and large projects tied to custom loaders. On the Bookstore widget we hit the loader-order error, write a manifest plugin and measure splitChunks' minSize limit and the cost of Module Federation.
Version note: The examples in this post were run with webpack 5.112, webpack-cli 7.3, webpack-dev-server 6.1, TypeScript 7.0 and Node.js 24; browser checks were done with Playwright 1.65's Chromium. Timings come from the author's own machine.
Guess First
We now build the Bookstore book search widget (Bookstore, the online bookstore used as the shared example across this site's series) that we built with Vite in the previous post, this time with webpack. Someone on the team copied a webpack config from a project a few years old. It uses mini-css-extract-plugin to pull CSS into a separate file and css-loader to resolve @import and url() in CSS. The rule looks like this:
module: {
rules: [
{ test: /\.ts$/, resolve: { fullySpecified: false } },
{ test: /\.css$/, use: ["css-loader", MiniCssExtractPlugin.loader] },
],
},
Read in order, the list looks sensible: resolve the CSS first, then extract it to a file. What happens when you run npx webpack -c webpack.loaders.config.mjs --mode production? Does the build pass with an empty CSS file, does the CSS end up embedded in the JavaScript, or does the build never finish? Keep your answer in mind.
This is the second post in the Build Tools series. In the previous post we saw the steps of a build; here we look at the tool that has implemented those steps the longest and still runs in the most projects: webpack's model, the loader chain, plugin hooks, the native CSS and HTML support that entered core over the last year, Module Federation, and when you still need webpack in 2026.
webpack's Model: Everything Is a Module
webpack's core idea is this: every file reachable from the entry is a module. A JavaScript file, a CSS file and an image are all nodes in the graph. webpack only understands JavaScript and JSON on its own; every other file type needs a module type or a loader chain. The five basic parts of a config follow from that model:
| Concept | Job | In the widget |
|---|---|---|
entry | The file or files where the graph starts | ./index.html or ./src/main.ts |
module.rules | Which loader and module type apply to which file | the .ts and .css rules |
plugins | Classes that hook into the compilation process | HTML generation, CSS extraction, a manifest |
output | Where files are written and under which name | [name].[contenthash:8].js |
mode | development or production defaults | minification, tree shaking, process.env.NODE_ENV |
A loader is a function that takes a file's content and returns other content. A plugin is a class with an apply(compiler) method that hooks into webpack's compilation lifecycle. A loader transforms a single file; a plugin sees the whole compilation.
The Loader Chain: Right to Left
The answer to the guess: the build fails with an error.
ERROR in ./src/styles.css
Module build failed (from ./node_modules/mini-css-extract-plugin/dist/loader.js):
Error: Execution of module code from module graph (./src/styles.css.webpack[javascript/auto]!=!./src/styles.css) failed: Module parse failed: Unexpected token (1:0)
File was parsed as module type 'javascript/auto'.
You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders
> 1 | #book-finder {
| ^
2 | max-width: 32rem;
@ ./src/main.ts 1:0-22
webpack 5.112.2 compiled with 2 errors in 206 ms
Loaders in a use array run from right to left (bottom to top if written vertically). The first link in the chain receives the file's raw content, and the last link must return JavaScript to webpack. In the order we wrote, the raw CSS went to MiniCssExtractPlugin.loader first. That loader expects its input to be the JavaScript module css-loader produces; it tried to execute it to extract the CSS and could not parse the line #book-finder { as JavaScript. The phrase "File was parsed as module type 'javascript/auto'" in the error is the typical signature of this ordering mistake. The correct order:
{ test: /\.css$/, use: [MiniCssExtractPlugin.loader, "css-loader"] },
First css-loader reads the CSS, turns @import and url() into module dependencies and returns the result as a JavaScript module; then MiniCssExtractPlugin.loader takes that module and has the CSS written to a separate file. In a Sass project the chain has three links: [MiniCssExtractPlugin.loader, "css-loader", "sass-loader"]; the rightmost one, sass-loader, runs first. With the correct order the build produces:
asset book-finder.fb0ab0aa.js 3.44 KiB [emitted] [immutable] [minimized] (name: book-finder)
asset book-finder.aeb8e5f8.css 407 bytes [emitted] [immutable] (name: book-finder)
asset index.html 141 bytes [emitted] [minimized]
webpack 5.112.2 compiled successfully in 323 ms
The JS is marked [minimized], the CSS is not. Looking at the emitted CSS file, it is identical to the source: the nested rules are still there, and so is the whitespace. On the loader path you needed css-minimizer-webpack-plugin to minify CSS, plus postcss-loader and a PostCSS plugin to lower nesting for older browsers. That is why configs grew long in older projects: every capability was one more package and one more rule.
webpack in 2026: Native CSS, HTML Entries and Built-in TypeScript
webpack's 2026 roadmap (4 February 2026) aimed to pull this crowd of packages into core, and most of it arrived in recent releases. 5.107 (May 2026) brought HTML modules and experimental TypeScript support based on Node.js type stripping. 5.108 (June) made it possible to use an HTML file directly as an entry. 5.109 (July) changed the default of experiments.css, experiments.html and experiments.typescript to 'auto': if the config has no loader rule for that file type, built-in support turns on by itself. 5.110 (August) deprecated css-loader, style-loader and mini-css-extract-plugin; they keep working but will not get new features. There is an official codemod for the migration: npx codemod @webpack/css-plugins-to-native-css.
Here is the same widget's config on 5.112:
import path from "node:path";
import webpack from "webpack";
import { AssetManifestPlugin } from "./webpack/asset-manifest-plugin.mjs";
export default webpack.defineConfig((env, argv) => ({
entry: { index: "./index.html" },
output: {
path: path.resolve(import.meta.dirname, "dist-webpack"),
filename: "[name].[contenthash:8].js",
cssFilename: "[name].[contenthash:8].css",
htmlFilename: "[name].html",
clean: true,
},
module: {
parser: {
html: { chunkName: "book-finder" },
},
rules: [
// Lets .ts imports omit the extension in a "type": "module" package
{ test: /\.ts$/, resolve: { fullySpecified: false } },
],
},
devtool: argv.mode === "production" ? false : "eval-source-map",
plugins: [new AssetManifestPlugin()],
}));
No loaders, no html-webpack-plugin. The entry is the very index.html Vite uses: webpack reads the script and link tags in the HTML as module dependencies, resolves /src/main.ts against the project root and rewrites those tags with hashed file names in the output. chunkName gives the chunk extracted from the HTML a readable name instead of an automatic one derived from the page and the tag position. defineConfig arrived in 5.108; it does nothing at run time and only provides type checking and completion in the editor.
The single rule is worth a look. The first attempt had no rules at all, and the build stopped with this error:
ERROR in ./src/main.ts 2:0-38
Module not found: Error: Can't resolve './books' in '.../widget/src'
Did you mean 'books.ts'?
BREAKING CHANGE: The request './books' failed to resolve only because it was resolved as fully specified
(probably because the origin is strict EcmaScript Module, e. g. a module with javascript mimetype, a '*.mjs' file, or a '*.js' file where the package.json contains '"type": "module"').
The extension in the request is mandatory for it to be fully specified.
Because package.json has "type": "module", webpack treats the modules in this package as strict ESM and, as in Node.js, wants file extensions in imports. Vite and esbuild resolve the extensionless ./books without complaint. There are two fixes: write extensions in the imports (./books.ts; tsconfig then needs allowImportingTsExtensions) or turn fullySpecified off for .ts files. We chose the second to leave the source alone.
npm run build:webpack
(node:1024) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
assets by info 3.83 KiB [immutable]
asset book-finder.fb0ab0aa.js 3.44 KiB [emitted] [immutable] [minimized] (name: book-finder)
asset book-finder.7d8ac157.css 396 bytes [emitted] [immutable] [minimized] (name: book-finder)
asset index.html 318 bytes [emitted] [minimized] (auxiliary name: index)
asset asset-manifest.json 88 bytes [emitted]
webpack 5.112.2 compiled successfully in 316 ms
The warning line comes from Node.js, not webpack: built-in TypeScript support uses the module.stripTypeScriptTypes API, which is still experimental in Node.js 24, and therefore needs Node.js 22.6 or later. Type stripping only removes types; it does not support syntax that generates more than types, such as enum, namespace or constructor parameter properties. The widget's tsconfig enforces exactly that limit in the editor with erasableSyntaxOnly. Type checking is still a separate step with npm run typecheck.
The CSS is now [minimized]: it went from 407 bytes to 396, the nesting was flattened, and fallback fonts for older browsers were added next to system-ui. Since the config has no browserslist, webpack's CSS minimizer uses Browserslist's defaults query. The same widget produced 4.04 kB of JS in Vite and 3.44 KiB in webpack. Take away the 670-byte modulepreload polyfill Vite adds and Vite's output ends up slightly smaller; we compare the tools in the last post of the series.
One warning: because TypeScript 7.0 has no programmatic API, ts-loader does not work with it. When we tried, the build crashed inside ts-loader with TypeError: Cannot read properties of undefined (reading 'fileExists'). If you must keep ts-loader in an existing project, you have to stay on TypeScript 6.0 or point ts-loader's compiler option at the TypeScript 6 compatibility package; we covered the details in the TypeScript Advanced series post "TypeScript 7 and Beyond: The Go-Based Compiler and the New Defaults".
Plugin Hooks: Writing Your Own Plugin
The AssetManifestPlugin in the config is a small plugin we wrote. Bookstore.Web's Razor layout has to know which hashed file to load; this plugin writes the files each entrypoint needs to asset-manifest.json:
import webpack from "webpack";
const { Compilation, sources } = webpack;
// Writes asset-manifest.json: which files each entrypoint needs
export class AssetManifestPlugin {
apply(compiler) {
compiler.hooks.compile.tap("AssetManifestPlugin", () => console.log("[manifest] compile"));
compiler.hooks.thisCompilation.tap("AssetManifestPlugin", compilation => {
compilation.hooks.processAssets.tap(
{ name: "AssetManifestPlugin", stage: Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE },
() => {
const manifest = {};
for (const [name, entrypoint] of compilation.entrypoints) {
const files = entrypoint.getFiles().filter(file => !file.endsWith(".map"));
if (files.length > 0) {
manifest[name] = files;
}
}
const json = JSON.stringify(manifest, null, 2);
compilation.emitAsset("asset-manifest.json", new sources.RawSource(json));
console.log("[manifest] processAssets: asset-manifest.json");
});
});
compiler.hooks.done.tap("AssetManifestPlugin", stats => console.log(`[manifest] done in ${stats.endTime - stats.startTime} ms`));
}
}
[manifest] compile
[manifest] processAssets: asset-manifest.json
[manifest] done in 316 ms
{
"book-finder": [
"book-finder.fb0ab0aa.js",
"book-finder.7d8ac157.css"
]
}
webpack has two main objects. compiler represents the whole process and is created once; compilation represents a single compilation and is recreated on every change in watch mode. Hooks are built with the Tapable library; tap attaches synchronously, tapAsync and tapPromise asynchronously. processAssets runs while the output files are being prepared, and the stage value sets the plugin's place in line: the SUMMARIZE stage comes after minification and hashing are done, which is why the manifest gets the final file names. A plugin that picks an earlier stage sees, for example, unminified content. The whole webpack ecosystem, from html-webpack-plugin to mini-css-extract-plugin, is built on these hooks; it is both webpack's strongest and its most complicated side.
mode and optimization.splitChunks
--mode production turns on minification, tree shaking, scope hoisting (called module concatenation in webpack) and the replacement of process.env.NODE_ENV with "production". The line ./src/main.ts + 19 modules in the output above is the result of that concatenation: 20 modules in one scope. In development mode none of this happens, module names stay readable and the build is fast.
Code splitting is managed by optimization.splitChunks. The default is chunks: "async", meaning only code loaded with a dynamic import() is split into separate chunks. We looked at what happens when you set chunks: "all" to "move vendor code into its own chunk":
npx webpack --mode production --optimization-split-chunks-chunks all
# book-finder.fb0ab0aa.js 3.44 KiB, no separate vendor chunk
npx webpack --mode production --optimization-split-chunks-chunks all --optimization-split-chunks-min-size 0
# asset 904.2148c5d5.js 2.28 KiB [emitted] [immutable] [minimized] (id hint: vendors)
# asset book-finder.836d2778.js 2.27 KiB [emitted] [immutable] [minimized] (name: book-finder)
The first attempt changed nothing, because minSize defaults to 20000 bytes and the 14 lodash modules we use fall below that limit. With the limit at zero the vendors chunk appeared, but the total JS went from 3.44 KiB to 4.55 KiB: the chunk-loading runtime that ties the two chunks together entered the bundle as well. In a small app, splitting out vendors is a loss, not a gain. We cover when splitting pays off, and how the reasoning changed after HTTP/2, in the series' code splitting post.
Micro-Frontends with Module Federation
Module Federation, which webpack 5 introduced in 2020, lets applications that are built and deployed separately load each other's modules at run time. It is used in micro-frontend architectures, for example when Bookstore's institutional portal team wants to ship its section independently of the main application. A config that publishes the widget as a remote:
import path from "node:path";
import webpack from "webpack";
const { ModuleFederationPlugin } = webpack.container;
export default webpack.defineConfig({
entry: {},
output: {
path: path.resolve(import.meta.dirname, "dist-federation"),
publicPath: "auto",
uniqueName: "bookFinder",
clean: true,
copy: "./federation",
},
module: {
rules: [{ test: /\.ts$/, resolve: { fullySpecified: false } }],
},
plugins: [
new ModuleFederationPlugin({
name: "bookFinder",
filename: "remoteEntry.js",
exposes: { "./finder": "./src/finder.ts" },
shared: { "lodash-es": { singleton: true } },
}),
],
});
output.copy arrived in 5.111 and copies the test page in the federation/ folder into the output. That page is plain HTML not built with webpack; it uses the remote container's protocol directly:
<script src="./remoteEntry.js"></script>
...
<script type="module">
await bookFinder.init({});
const factory = await bookFinder.get("./finder");
const { mountBookFinder } = factory();
mountBookFinder(document.querySelector("#book-finder"), [
{ isbn: "9780441172719", title: "Dune", author: "Frank Herbert", price: 450 },
{ isbn: "9780141439587", title: "Emma", author: "Jane Austen", price: 210 },
]);
</script>
npm run build:federation
# asset 271.js 87.4 KiB [emitted] [minimized] (id hint: vendors) 1 related asset
# asset remoteEntry.js 6.02 KiB [emitted] [minimized] (name: bookFinder)
# asset 179.js 896 bytes [emitted] [minimized]
# asset host.html 489 bytes [emitted] [from: federation/host.html] [copied] [minimized]
When we opened the page in Chromium and typed "emma" into the box, the list narrowed to Emma · Jane Austen · ₺210,00; the widget was loaded from another build and ran. But watch the sizes: the widget, 3.44 KiB in the normal build, now requests an 87.4 KiB vendor chunk. The cause is the shared setting. A shared package cannot go through tree shaking, because the remote cannot know which exports the host will use from it; all of lodash-es goes into the chunk. That is usually where the real cost of Module Federation lies. In a real project the host is also built with webpack or Rspack and connects to the remote with the remotes option; the runtime makes the init and get calls itself. Module Federation 2.0 (@module-federation/enhanced) adds runtime plugins, type hints and a manifest, and carries the same idea over to Rspack and Vite.
When Do You Still Need webpack in 2026?
There is no strong reason to start a new SPA or library with webpack in 2026; Vite gives a faster development loop with less configuration. These are the places where webpack is still the right choice or simply unavoidable:
- Older Angular projects: the
@angular-devkit/build-angular:browserbuilder is webpack-based and deprecated;ng update @angular/cli --name use-application-buildermigrates to the application builder. Projects whose config was customized with@angular-builders/custom-webpackstruggle the most with that migration, because webpack plugins have no one-to-one equivalent on the esbuild side. We cover this migration in the series' Angular CLI post. - Micro-frontends built on Module Federation: the most mature and most widely used form of that ecosystem is still on the webpack and Rspack side.
- Large code bases that depend on custom loaders and plugins: the cost of moving behavior accumulated over years to another tool can be higher than the cost of staying on webpack.
For that last group, the middle road is Rspack. Rspack is a Rust-based bundler that largely supports webpack's config and plugin API. In version 2.0, released on 22 April 2026, the core packages are published as ESM only, and tree shaking support was added for shared packages in Module Federation; that is exactly the answer to the 87 KiB problem we saw above. According to the announcement, in the Rspack team's own benchmark an uncached production build is about 10% faster than 1.7 and close to twice as fast as 1.0. Those numbers were measured on their project; you cannot know whether they hold for yours without measuring.
webpack's own path is webpack 6. According to the roadmap, native CSS and HTML support will stop being experimental in 6, and packages such as mini-css-extract-plugin and html-webpack-plugin will no longer be needed. In 5.111, output.module (ESM output) leaving its experimental flag and joining experiments.futureDefaults is part of that preparation. No date has been announced.
In Production
Turn on the persistent cache. cache: { type: "filesystem" } writes compiled modules to disk and does not reprocess unchanged files in the next build. In large projects this is the biggest CI gain; remember to put the cache folder (node_modules/.cache/webpack) into the CI cache and to add the config file to the cache key with cache.buildDependencies, otherwise a config change gets mixed with the old cache.
Choose a source map strategy for production builds deliberately. The devtool in the config is false in production; we look at hidden source maps and their use with error tracking services in the series' source map post.
Watch the bundle size. performance.hints: "stats", which came with webpack 5.110, and the unusedModules, duplicateModules and unusedConfig checks in 5.111 can report unused modules and idle config rules in the build output. All of them are off by default.
When moving an old loader-based config to native CSS, compare the output. On the same widget we saw 407 bytes of unminified CSS shrink to 396 bytes with the nesting flattened; in a project with many CSS files, ordering and CSS Modules class names can change too. After running the codemod, verify the look of critical pages with a test.
Common Pitfalls
First, growing the config by copy and paste. Every rule and plugin taken from the internet carries assumptions: which webpack version, which loader version, which Node.js. If you see file-loader, url-loader or raw-loader in a five-year-old config, webpack 5's asset modules (type: "asset/resource", "asset/inline", "asset/source") replaced them long ago.
Second, assuming the loader order is the reading order. The use array runs right to left; the last link of the chain must always return JavaScript. The error "File was parsed as module type 'javascript/auto'" usually signals a wrong order or a missing loader.
Third, trying to fix slow HMR by adding plugins. Every plugin adds one more job to the hooks; what slows HMR down is usually extra work. First measure which loader and plugin spend the time: npx webpack --mode production --profile --json=stats.json writes per-module timings to a file. (Redirecting with --json > stats.json produces broken JSON if a plugin like ours writes to the console.) Then turn off unnecessary work in development (minification, heavy source map types, plugins that type-check in the same process) and turn on the persistent cache. If that is not enough, the right answer is most likely moving to Rspack or Vite.
Fourth, trying to use ts-loader with TypeScript 7. As we saw above, it does not work; webpack's built-in type stripping is enough for a new project, TypeScript 6 for an existing one.
A Small Experiment
★☆☆ Reverse the order of the CSS rule in webpack.loaders.config.mjs, see the error yourself with npm run build:webpack-loaders and fix it. Then, to see the relationship between type: "module" and package.json, delete the .ts rule in the same config and read the error that comes out.
★★☆ Extend AssetManifestPlugin to also write each file's size (compilation.getAsset(file).source.size()). Change the stage to PROCESS_ASSETS_STAGE_ADDITIONS and run it again; check whether the written names and sizes change and explain why.
★★★ Migrate a copy of webpack.loaders.config.mjs to native CSS by hand: delete the CSS rule and MiniCssExtractPlugin, add output.cssFilename. Compare the size and content of the emitted CSS file with the native config's output; then read in the webpack 5.110 announcement how the official codemod does the same job.
In the next post we move to a tool written with the opposite philosophy: esbuild. On the same widget we see why this Go-based tool, which relies on an API more than on configuration, is so fast, how its plugin API works and where it falls short.