Tutorial · JavaScript

Webpack 5 tutorial: configure an existing build (Babel, SASS, PostCSS)

10 min · Beginner · CSS, JavaScript · verified on 7 September 2026

Quick answer

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.

bash
mkdir -p src dist
npm init --yes
npm install --save-dev webpack webpack-cli webpack-dev-server

The minimal configuration fits in three keys. entry is the starting point, output the file produced, devServer the folder served during development.

webpack.config.js
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:

package.json
{
  "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:

bash
<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' directory

Babel: 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.

bash
npm install --save-dev @babel/core @babel/preset-env babel-loader
webpack.config.js
module.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:

.babelrc
{
  "presets": ["@babel/preset-env"]
}

With Babel 8.0.1, applied to this source file:

src/index.js
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:

dist/bundle.js
(()=>{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.

.babelrc
{
  "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.

bash
npm install --save-dev css-loader style-loader
webpack.config.js
module.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:

src/index.js
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:

bash
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.js

And 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:

bash
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 error

Two 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.

bash
npm uninstall node-sass
npm install --save-dev sass sass-loader
webpack.config.js
module.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.

src/style.scss
$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.

bash
npm install --save-dev postcss postcss-loader postcss-preset-env
webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.s?[ac]ss$/i,
        use: ['style-loader', 'css-loader', 'postcss-loader', 'sass-loader'],
      },
    ],
  },
};
postcss.config.js
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:

dist/main.[contenthash].css
#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:

css
/* 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:

package.json
{
  "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.

bash
npm install --save-dev mini-css-extract-plugin html-webpack-plugin

Here is the complete configuration, the one used for every measurement on this page:

webpack.config.js
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.

bash
NODE_ENV=production npx webpack
bash
asset 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 successfully

The generated HTML references both hashed files, with no manual work:

dist/index.html
<!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.

webpack.config.js
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:

bash
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 ms

Keep 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

node-sass stops the build 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.
@babel/preset-env without targets no longer transpiles Since Babel 8 the default target has changed: modern code comes out untouched. Declare targets or a browserslist field.
browsers: 'last 2 versions' lets Internet Explorer back in In browserslist that query returns 30 targets including ie 11 and ie 10, hence useless -ms-flexbox prefixes. The browsers option is deprecated anyway.
style-loader and MiniCssExtractPlugin in the same rule The two are mutually exclusive. Keep style-loader in development and switch to extraction in production.
output.path has to be absolute A value such as '/dist' points at the root of the disk. Use path.resolve(__dirname, 'dist').
Loader order reads from right to left ['style-loader', 'css-loader'] runs css-loader first. Reversed, the build fails.
Newsletter

New tests, tutorials and projects, by e-mail.

Reproducible tests, versioned code, dated results. Never any spam.