Webpack 5 tutorial: configure an existing build (Babel, SASS, PostCSS)
10 min · Beginner · CSS, JavaScript · verified on 7 September 2026
On a Webpack 5 configuration picked up today, two things break silently: node-sass, which sass-loader 17 no longer recognises at all, and @babel/preset-env with no declared target, which since Babel 8 transpiles nothing. This guide gives the complete configuration, foundation, Babel, CSS, SASS, PostCSS, production, re-run on 2 September 2026 on Webpack 5.110.3 and Node 22.
This guide starts from a simple observation: in 2026 you rarely open a Webpack project to create it, you open it to repair it. The four pieces that cause trouble are always the same — transpiling JavaScript, loading CSS, compiling SCSS, applying PostCSS. They are gathered here, in a single configuration tested on Webpack 5.110.3 and Node 22.
Should you still be using Webpack in 2026?
The question deserves a straight answer before you configure anything, because it decides how much effort you are about to invest.
For a new project, no. Vite is the default choice for Vue and for Laravel, and it is where the documentation of most front-end frameworks points. Starting a project on Webpack in 2026 means hand-writing a configuration that the other tools already provide.
For an existing project, yes, and with no guilt. Webpack has not been abandoned: version 5.110.3 is the one used for this guide, the 2026 roadmap announces native CSS, built-in TypeScript transpilation and HTML entry points, and version 6 is scheduled for the end of 2027. A codebase with a heavy configuration, in-house loaders or specific plugins has no reason to migrate in a hurry.
The rule of thumb: if your cold start stays under five seconds, this is not a priority. If it runs past thirty seconds or so, migrating becomes a real gain in comfort. The migrating to Vite or Rspack section at the end of the guide gives the starting points.
The videos in this guide date from the original publication, in February 2022. They show the general approach, but the up-to-date versions and code are the ones in the text below.
The foundation: entry, output and development server
Webpack reads an entry file, follows the import statements one by one and writes an output file. Everything else is just an addition to that mechanism.
mkdir -p src dist
npm init --yes
npm install --save-dev webpack webpack-cli webpack-dev-serverThe minimal configuration fits in three keys. entry is the starting point, output the file produced, devServer the folder served during development.
const path = require('path');
module.exports = {
entry: path.resolve(__dirname, 'src/index.js'),
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js',
},
devServer: {
static: { directory: path.resolve(__dirname, 'dist') },
port: 8080,
},
};Two points that old configurations often get wrong. output.path has to be an absolute path, hence the path.resolve(__dirname, …): a value such as '/dist' points at the root of the disk. And since Webpack 5, devServer.static replaces the old contentBase.
Add the npm scripts, then start the server:
{
"scripts": {
"start": "webpack serve --mode development",
"build": "webpack --mode production"
}
}On the test machine, npm start does answer with HTTP 200 on http://localhost:8080/ using webpack-dev-server 6.0.0:
<i> [webpack-dev-server] Project is running at:
<i> [webpack-dev-server] Loopback: http://localhost:8080/, http://[::1]:8080/
<i> [webpack-dev-server] Content not from webpack is served from '/app/dist' directoryBabel: what changed, and why your configuration no longer transpiles
This is the most important part of this update, and the one that silently breaks old projects. Babel 8 changed its default target. A configuration that declares @babel/preset-env without naming a target no longer transpiles anything.
npm install --save-dev @babel/core @babel/preset-env babel-loadermodule.exports = {
module: {
rules: [
{
test: /\.m?js$/,
exclude: /node_modules/,
use: 'babel-loader',
},
],
},
};Here is the Babel configuration as you find it in most tutorials, including the 2022 version of this page:
{
"presets": ["@babel/preset-env"]
}With Babel 8.0.1, applied to this source file:
const nombres = [1, 2, 3, 4];
const doubles = nombres.map((n) => n * 2);
const total = doubles?.reduce((a, b) => a + b, 0) ?? 0;
class Compteur { #valeur = total; lire() { return this.#valeur; } }
document.querySelector('#app').textContent = `Total : ${new Compteur().lire()}`;it produces a 173-byte bundle in which the arrow functions, the optional chaining ?., the nullish coalescing ?? and the private field #valeur are all still there, untouched:
(()=>{const e=[1,2,3,4].map(e=>2*e),t=e?.reduce((e,t)=>e+t,0)??0;
document.querySelector("#app").textContent=`Total : ${(new class{#e=t;lire(){return this.#e}}).lire()}`})();In other words, Babel runs, costs build time, and transforms nothing. The fix is a single key: declare the target explicitly.
{
"presets": [
["@babel/preset-env", { "targets": { "ie": "11" } }]
]
}With that target, the same code produces 1.48 KiB of ES5 output, with the _typeof and _defineProperty helpers you expect from a real transpilation.
There is a nuance to understand here rather than a reflex to apply: with a modern target such as "defaults", the output stays identical to the input, and that is normal. Every browser still tracked handles arrow functions, ?., ?? and private fields natively. If you have no old browser to serve, Babel has little left to do in your build chain. Removing it is a legitimate decision, not a shortcut.
CSS: loading stylesheets from JavaScript
Two loaders work as a pair. css-loader resolves @import and url() and turns the stylesheet into a JavaScript module; style-loader injects that module into a <style> tag when the page loads.
npm install --save-dev css-loader style-loadermodule.exports = {
module: {
rules: [
{
test: /\.css$/i,
use: ['style-loader', 'css-loader'],
},
],
},
};Order matters, and it reads from right to left: css-loader processes the file, then style-loader injects the result. Swap the two and the build fails.
All that is left is to import the stylesheet from the JavaScript:
import './style.css';After the build, the colour declared in style.css does end up inside dist/bundle.js: in development, the CSS travels inside the JavaScript. For production, the next section extracts it into a real .css file.
SASS: compiling SCSS, and getting rid of node-sass
If you are picking up a project set up before 2023, this is where the build breaks. The historical instruction was npm install --save-dev sass-loader node-sass. On Node 22 it already fails at install time: node-sass has no binary for that Node version and its native build stops, without sass-loader being installed either:
npm warn deprecated node-sass@9.0.0: Node Sass is no longer supported. Please use `sass` or `sass-embedded` instead.
npm error code 1
npm error path /app/node_modules/node-sass
npm error command failed
npm error command sh -c node scripts/build.jsAnd on a project where node-sass was already present in node_modules, it is the build that breaks, with a clear error and exit code 1:
ERROR in ./src/style.scss
Module build failed (from ./node_modules/sass-loader/dist/cjs/index.js):
Error: Unknown Sass implementation "node-sass".
at getSassImplementation (/app/node_modules/sass-loader/dist/cjs/utils.js:168:9)
webpack 5.110.3 compiled with 1 errorTwo things happened. The node-sass package is deprecated — npm says so itself, “Node Sass is no longer supported” — and above all sass-loader 17 dropped support for that implementation. There is no workaround: you have to switch to sass, the Dart implementation.
npm uninstall node-sass
npm install --save-dev sass sass-loadermodule.exports = {
module: {
rules: [
{
test: /\.s[ac]ss$/i,
use: ['style-loader', 'css-loader', 'sass-loader'],
},
],
},
};No option is needed: sass-loader detects sass on its own. The chain still reads from right to left — SCSS compiled to CSS, then loaded, then injected.
$couleur: #d62828;
#app {
color: $couleur;
display: flex;
&:hover { text-decoration: underline; }
}With sass 1.103.1 the build passes and the variable is properly resolved: color:#d62828 shows up in the output.
PostCSS: prefixes and modern syntax
Unlike Babel, PostCSS has kept all of its usefulness. It adds the vendor prefixes and lowers recent CSS syntax to a form your target browsers understand.
npm install --save-dev postcss postcss-loader postcss-preset-envmodule.exports = {
module: {
rules: [
{
test: /\.s?[ac]ss$/i,
use: ['style-loader', 'css-loader', 'postcss-loader', 'sass-loader'],
},
],
},
};module.exports = {
plugins: [require('postcss-preset-env')({ stage: 2 })],
};postcss-loader sits between css-loader and sass-loader: the SCSS is compiled, PostCSS works on the resulting CSS, then css-loader takes over.
On the rule from the previous section, with user-select and inset added, the output shows the work actually done:
#app{color:#d62828;display:flex;-webkit-user-select:none;-moz-user-select:none;
user-select:none;top:0;right:0;bottom:0;left:0}
#app:hover{-webkit-text-decoration:underline;text-decoration:underline}The prefixes are added and inset: 0 is expanded into four properties. That is a concrete gain, unlike Babel on a modern target.
The trap to know about. Plenty of configurations, including the 2022 version of this page, pass the browsers: 'last 2 versions' option to postcss-preset-env. That option is deprecated, and the query itself is a trap: in browserslist, last 2 versions means “the last two versions of every browser”, Internet Explorer included. Checked against caniuse-lite 1.0.30001810, that query returns 30 targets including ie 11, ie 10, ie_mob and op_mini all. Result measured on the same file:
/* with browsers: 'last 2 versions' — IE included */
display:-webkit-box; display:-ms-flexbox; display:flex;
/* with the default target (defaults) — 32 targets, no IE */
display:flex;The good practice is to drop the browsers option and declare a browserslist field in package.json, which PostCSS, Babel and the rest of the chain all read:
{
"browserslist": ["defaults", "not dead"]
}Going to production: extracted CSS, generated HTML, hashing
Three additions separate the development configuration from a shippable build. mini-css-extract-plugin pulls the CSS out of the JavaScript bundle into a standalone file. html-webpack-plugin generates index.html and injects the right file names into it. [contenthash] and output.clean take care of caching and cleanup.
npm install --save-dev mini-css-extract-plugin html-webpack-pluginHere is the complete configuration, the one used for every measurement on this page:
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const enProduction = process.env.NODE_ENV === 'production';
module.exports = {
mode: enProduction ? 'production' : 'development',
entry: path.resolve(__dirname, 'src/index.js'),
output: {
path: path.resolve(__dirname, 'dist'),
filename: enProduction ? '[name].[contenthash].js' : '[name].js',
clean: true,
},
module: {
rules: [
{ test: /\.m?js$/, exclude: /node_modules/, use: 'babel-loader' },
{
test: /\.s?[ac]ss$/i,
use: [
enProduction ? MiniCssExtractPlugin.loader : 'style-loader',
'css-loader',
'postcss-loader',
'sass-loader',
],
},
],
},
plugins: [
new HtmlWebpackPlugin({ template: path.resolve(__dirname, 'src/index.html') }),
...(enProduction ? [new MiniCssExtractPlugin({ filename: '[name].[contenthash].css' })] : []),
],
devServer: {
static: { directory: path.resolve(__dirname, 'dist') },
port: 8080,
hot: true,
},
};The thing to remember: MiniCssExtractPlugin.loader and style-loader are mutually exclusive. Keep style-loader in development, for hot reloading, and switch to extraction in production.
NODE_ENV=production npx webpackasset index.html 240 bytes [emitted] [minimized]
asset main.ca8a5113b21c83c25e3e.css 198 bytes [emitted] [immutable] (name: main)
asset main.b0cab13462d9bb7b12ba.js 99 bytes [emitted] [immutable] (name: main)
webpack 5.110.3 compiled successfullyThe generated HTML references both hashed files, with no manual work:
<!doctype html><html lang=fr><head><meta charset=utf-8><title>Hello Webpack</title>
<script defer src=main.b0cab13462d9bb7b12ba.js></script>
<link href=main.ca8a5113b21c83c25e3e.css rel=stylesheet></head>
<body><div id=app></div></body></html>The loader-free alternative: Webpack’s native CSS
Webpack can load CSS with no loader at all, through an experimental option. It is one of the projects announced in the 2026 roadmap, and it is meant to become the default behaviour in version 6.
module.exports = {
mode: 'production',
entry: path.resolve(__dirname, 'src/index.js'),
output: { path: path.resolve(__dirname, 'dist'), filename: 'bundle.js', clean: true },
experiments: { css: true },
};With no css-loader, no style-loader and no mini-css-extract-plugin, the build does produce a separate CSS file:
asset bundle.js 71 bytes [emitted] [minimized] (name: main)
asset bundle.css 32 bytes [emitted] [minimized] (name: main)
webpack 5.110.3 compiled successfully in 1392 msKeep it for projects that only need plain CSS: the option is still experimental, and it replaces neither SASS nor PostCSS, which still require their loaders.
Migrating to Vite or Rspack: where to start
If the diagnosis in the first section points you towards a migration, there are two paths, and they do not cost the same.
Rspack is a Rust rewrite of Webpack that keeps the configuration model and broad compatibility with the plugin API. It is the cheaper path when the existing configuration is heavy: the entry, output and loader rule concepts covered in this guide still hold.
Vite forces you to rethink the configuration, but it is the ecosystem’s default target. The migration mostly comes down to the specific plugins and the non-standard imports. The French guides listed in the sources report about a day of work on a mid-sized Vue project.
In both cases, the work in this guide is not wasted: knowing exactly what each loader in your current configuration does is precisely what lets you decide what to carry over and what to drop.
This guide is part of the foundations gathered on the Web development hub.
Common errors
sass-loader 17 dropped that implementation: the build fails on “Unknown Sass implementation "node-sass"” with exit code 1. Replace it with the sass package.targets or a browserslist field.ie 11 and ie 10, hence useless -ms-flexbox prefixes. The browsers option is deprecated anyway.style-loader in development and switch to extraction in production.'/dist' points at the root of the disk. Use path.resolve(__dirname, 'dist').['style-loader', 'css-loader'] runs css-loader first. Reversed, the build fails.