Understanding Webpack Configuration
You inherit a project, run npm run build, and get Module parse failed: Unexpected token pointing at a CSS file. Or the build works, but the bundle is 3 MB and nobody knows why. You open webpack.config.js, see 200 lines of loaders, plugins and cache groups, and have no idea which ones still matter.
This post walks through a Webpack config one piece at a time, with the reason each piece is there. I'll cover entry and output, loaders, plugins, code splitting, separate dev and prod configs, tree shaking, and how to find out what's actually in your bundle.
Everything here is for Webpack 5. If you're still on Webpack 4, some things differ: asset modules don't exist (you'd use file-loader/url-loader), and output.clean isn't available.
Why Webpack Exists
Before browsers natively supported ES modules, JavaScript developers had a problem: how do you ship hundreds of files (JS modules, CSS, images, fonts) to the browser without it crawling? Loading them one by one means dozens of HTTP requests and no automatic dependency resolution. Webpack handles this by traversing your dependency graph starting from an entry point, collecting all required modules, and bundling them into one or more output files.
Browsers handle ES modules natively now, but a bundler still earns its place in most production apps:
- Transforms: Convert TypeScript, JSX, SCSS, and modern JS syntax for older browsers via Babel
- Optimization: Tree shaking removes dead code; minification shrinks file sizes
- Asset management: Handles images, fonts, and SVGs as first-class modules
- Code splitting: Splits bundles into chunks loaded on demand, reducing initial load time
- Hot Module Replacement: Updates modules in the browser without a full page reload during development
Webpack traverses all imports, applies loaders and plugins, then emits optimized output files
Installation and Project Setup
Webpack requires Node.js. You'll install Webpack itself and the CLI separately:
Initialize a Node project
Create a new project directory and generate a package.json file to track dependencies.
mkdir my-webpack-project && cd my-webpack-project
npm init -y
Install Webpack and the CLI
Install both as dev dependencies. They're only needed at build time, not at runtime.
npm install --save-dev webpack webpack-cli
Create the project structure
Set up a standard directory layout with source files and an HTML entry point.
my-webpack-project/
├── src/
│ ├── index.js ← entry point
│ ├── utils.js
│ └── styles.css
├── dist/ ← Webpack output goes here
├── index.html
├── webpack.config.js
└── package.json
The Core Configuration File
Webpack reads webpack.config.js from the project root. It exports an object (or a function, or an array of objects) describing what to build and how. We'll grow one step by step.
Entry and Output
The entry point is where Webpack begins building its dependency graph. The output tells Webpack where to emit the bundle and what to name it.
const path = require('path');
module.exports = {
// Entry: where Webpack starts building the graph
entry: './src/index.js',
// Output: where to emit the bundle
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js',
// Clean the /dist folder before each build
clean: true,
},
// Mode affects optimizations applied by default
mode: 'development', // or 'production'
};
The path.resolve(__dirname, 'dist') pattern matters. Webpack needs an absolute path for the output directory (pass a relative one and it fails with a "configuration.output.path: The provided value is not an absolute path" validation error), so we use Node's built-in path module to construct one relative to the config file's location.
Multiple Entry Points
For multi-page applications, you can define multiple entry points. Webpack will produce a separate bundle for each:
module.exports = {
entry: {
main: './src/index.js',
admin: './src/admin.js',
},
output: {
// [name] is replaced with the entry key (main, admin)
filename: '[name].[contenthash].js',
path: path.resolve(__dirname, 'dist'),
clean: true,
},
};
Using [contenthash] in the filename puts a hash of the file's content in the filename. Change the code and the filename changes, so browsers fetch the new file. Leave it alone and the name stays the same, so the cached copy keeps working. That lets you serve these files with long cache headers in production.
Loaders: Processing Non-JS Files
By default, Webpack only understands JavaScript and JSON. Import a .css file without a loader and you get Module parse failed: Unexpected token ... You may need an appropriate loader to handle this file type. Loaders fix that: they turn CSS, TypeScript, images and fonts into modules Webpack can put in the dependency graph.
Loaders are configured under the module.rules array. Each rule specifies a test (a regex matching file extensions) and the loader(s) to apply.
CSS Loaders
To import CSS from JavaScript, you need two loaders working together: css-loader resolves @import and url() statements, and style-loader injects the resulting CSS into the DOM via a <style> tag.
npm install --save-dev css-loader style-loader
module.exports = {
// ...
module: {
rules: [
{
test: /\.css$/i,
// Loaders run RIGHT to LEFT (css-loader first, then style-loader)
use: ['style-loader', 'css-loader'],
},
],
},
};
Webpack applies loaders in the use array from right to left (or bottom to top if using the object form). In the CSS example, css-loader runs first to parse the CSS, then style-loader injects it. Reversing the order will cause errors.
Babel Loader: Transpiling Modern JavaScript
Babel turns modern JavaScript (ES2022+, JSX) into something older browsers can run. babel-loader is the glue between Webpack and Babel:
npm install --save-dev babel-loader @babel/core @babel/preset-env @babel/preset-react
module.exports = {
module: {
rules: [
{
test: /\.(js|jsx)$/,
// Exclude node_modules; they should already be transpiled
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: [
'@babel/preset-env', // Modern JS → ES5
'@babel/preset-react', // JSX → createElement calls
],
},
},
},
],
},
};
TypeScript Loader
# Install
npm install --save-dev ts-loader typescript
# webpack.config.js rule
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
}
Asset/Resource Loaders (Images, Fonts)
Webpack 5 has built-in asset modules, which replace file-loader and url-loader. No extra packages for images and fonts:
{
// Images
test: /\.(png|jpg|gif|svg)$/i,
type: 'asset/resource',
// Webpack emits the file to dist/ and returns the URL
},
{
// Fonts
test: /\.(woff|woff2|eot|ttf|otf)$/i,
type: 'asset/resource',
},
{
// Inline small assets as base64 data URLs (< 8kb by default)
test: /\.svg$/i,
type: 'asset',
parser: {
dataUrlCondition: {
maxSize: 8 * 1024, // 8kb
},
},
},
Plugins: Working on the Whole Build
Loaders work on one file at a time. Plugins hook into the build as a whole: generating HTML, pulling CSS out into its own files, injecting environment values, reporting bundle sizes.
HtmlWebpackPlugin: Auto-generating HTML
Once filenames contain hashes, you can't hardcode <script> tags anymore. This plugin writes the HTML file for you and injects the right tags, hashed names included:
# Install
npm install --save-dev html-webpack-plugin
# webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
// ...
plugins: [
new HtmlWebpackPlugin({
template: './index.html', // Use your HTML as a template
title: 'My App',
filename: 'index.html', // Output file in /dist
minify: { // Minify in production
removeComments: true,
collapseWhitespace: true,
},
}),
],
};
MiniCssExtractPlugin: Separate CSS Files
In production, injecting CSS via style-loader causes a flash of unstyled content (FOUC), because styles only appear once the JS has run. Extract CSS into its own file so the browser loads it in parallel:
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
// Use MiniCssExtractPlugin.loader instead of style-loader in production
{
test: /\.css$/i,
use: [
isProd ? MiniCssExtractPlugin.loader : 'style-loader',
'css-loader',
],
},
plugins: [
new MiniCssExtractPlugin({
filename: '[name].[contenthash].css',
}),
],
DefinePlugin: Environment Variables
The built-in DefinePlugin does a find-and-replace on your source at compile time. That's how process.env.NODE_ENV ends up as a literal string in browser code:
const webpack = require('webpack');
plugins: [
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
'process.env.API_URL': JSON.stringify('https://api.example.com'),
__DEV__: process.env.NODE_ENV !== 'production',
}),
],
Webpack 5 already sets process.env.NODE_ENV based on mode. If you also define it and the two disagree (say mode: 'production' but the shell's NODE_ENV is unset), you'll see a Conflicting values for 'process.env.NODE_ENV' warning, and React may ship its slower development build. Either drop that line or make sure both come from the same source.
Code Splitting: Loading Only What's Needed
One big bundle is fine for a small app. As the codebase grows, users on the login page end up downloading your admin dashboard's chart library. Code splitting breaks the bundle into chunks that load when they're needed, so the first page has less to download and parse.
Dynamic Imports
The most useful tool here is dynamic import(). Webpack creates a separate chunk for anything you import this way:
// This button handler only loads the chart library when clicked
document.getElementById('loadChart').addEventListener('click', async () => {
// Webpack splits this into a separate chunk (e.g. chart.chunk.js)
const { renderChart } = await import(
/* webpackChunkName: "chart" */ './charts/ChartRenderer'
);
renderChart('#container', data);
});
// React lazy loading works the same way
import React, { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('./pages/Dashboard'));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Dashboard />
</Suspense>
);
}
SplitChunksPlugin: Vendor Splitting
The built-in SplitChunksPlugin can pull shared dependencies (React, lodash) into a separate vendors chunk. Vendor code changes much less often than your app code, so that chunk stays cached across most deploys:
module.exports = {
optimization: {
splitChunks: {
chunks: 'all', // Split both async and sync chunks
cacheGroups: {
// Bundle all node_modules into a vendors chunk
vendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
priority: 20,
},
// Extract common modules used in 2+ chunks
common: {
name: 'common',
minChunks: 2,
chunks: 'all',
priority: 10,
reuseExistingChunk: true,
},
},
},
},
};
Illustrative sizes: with splitting, the first load only pulls what the current page needs
Development vs Production Configuration
Dev and prod want different things: fast rebuilds and readable errors locally, small cached files in production. A common setup is three files (a shared base, a dev config and a prod config) combined with webpack-merge.
npm install --save-dev webpack-merge
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].[contenthash].js',
clean: true,
},
plugins: [
new HtmlWebpackPlugin({ template: './index.html' }),
],
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
use: 'babel-loader',
},
{
test: /\.(png|jpg|svg)$/i,
type: 'asset/resource',
},
],
},
};
const { merge } = require('webpack-merge');
const common = require('./webpack.common.js');
module.exports = merge(common, {
mode: 'development',
// Source maps for easy debugging (fast, less accurate)
devtool: 'eval-source-map',
// Dev server with Hot Module Replacement
devServer: {
static: './dist',
hot: true,
port: 3000,
open: true,
historyApiFallback: true, // For React Router
},
module: {
rules: [
{
test: /\.css$/i,
use: ['style-loader', 'css-loader'], // Inject CSS in <style> tags
},
],
},
});
const { merge } = require('webpack-merge');
const common = require('./webpack.common.js');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
const TerserPlugin = require('terser-webpack-plugin');
module.exports = merge(common, {
mode: 'production',
// Higher quality source maps for error tracking (slower build)
devtool: 'source-map',
module: {
rules: [
{
test: /\.css$/i,
use: [MiniCssExtractPlugin.loader, 'css-loader'],
},
],
},
plugins: [
new MiniCssExtractPlugin({
filename: '[name].[contenthash].css',
}),
],
optimization: {
minimizer: [
new TerserPlugin(), // Minify JavaScript
new CssMinimizerPlugin(), // Minify CSS
],
splitChunks: { chunks: 'all' },
// Keep the runtime chunk separate for better caching
runtimeChunk: 'single',
},
});
{
"scripts": {
"start": "webpack serve --config webpack.dev.js",
"build": "webpack --config webpack.prod.js",
"build:analyze": "webpack --config webpack.prod.js --profile --json > stats.json"
}
}
Tree Shaking and Further Optimizations
You import one helper from a utils file with twenty exports. Ideally the other nineteen never reach the browser. That's tree shaking: dropping exports nothing imports. Webpack does it automatically in production mode, but only for ES modules (import/export), not CommonJS (require()).
// Named exports: tree-shakeable
export function add(a, b) { return a + b; }
export function multiply(a, b) { return a * b; }
export function formatCurrency(n) { /* ... */ }
// src/index.js
import { add } from './utils'; // Only `add` is included in the bundle
// multiply and formatCurrency are "shaken" out
console.log(add(2, 3));
For tree shaking to work with third-party libraries, they have to publish ES module builds. Many do (lodash-es, date-fns); plain lodash doesn't, which is why it shows up whole in so many bundles. You can also tell Webpack which of your own files have no side effects:
{
// false = every file in this package is side-effect-free
"sideEffects": false,
// Or list files that DO have side effects (like CSS imports)
"sideEffects": ["*.css", "./src/polyfills.js"]
}
Be careful with "sideEffects": false. If you set it and forget to whitelist CSS, any import './styles.css' with no bindings gets dropped in production, and your styles quietly vanish while dev looks fine.
| Feature | Development | Production |
|---|---|---|
| Source Maps | eval-source-map (fast) |
source-map (accurate) |
| CSS Handling | style-loader (injected) | MiniCssExtractPlugin (separate file) |
| JS Minification | None | TerserPlugin |
| Tree Shaking | Disabled | Enabled automatically |
| Code Splitting | Optional | Enabled via splitChunks |
| Hot Reloading | Enabled (HMR) | Not needed |
| Filename Hash | No (for speed) | contenthash (cache busting) |
Debugging and Analyzing Your Bundle
When your bundle is larger than expected, use the Webpack Bundle Analyzer to visualize what's taking up space:
# Install
npm install --save-dev webpack-bundle-analyzer
# Add to webpack.prod.js
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: 'static', // Generate HTML report
reportFilename: 'report.html',
openAnalyzer: true,
}),
],
The analyzer opens an interactive treemap showing every module in your bundle by size. Common findings include accidentally bundling all of lodash instead of just the functions you use, or including locale data for date libraries you don't need.
Common Webpack Gotchas and Fixes
- Huge bundle from moment.js: Moment.js bundles all locale files by default. Use
webpack.IgnorePluginto exclude them, then import only what you need:new webpack.IgnorePlugin({ resourceRegExp: /^\.\/locale$/, contextRegExp: /moment$/ }) - CSS not extracted in dev:
MiniCssExtractPlugin.loaderdoesn't support HMR. Usestyle-loaderin development andMiniCssExtractPlugin.loaderin production. - "Module not found" for CSS imports in JS: Make sure you have both
css-loaderandstyle-loaderinstalled and configured in the correct order. - Vendor hash changes when vendors didn't: Set
runtimeChunk: 'single'in production. Without it, the runtime (the chunk manifest) is embedded in an entry chunk, so changing one file can change hashes of files you never touched. - Slow builds: Add
cache: { type: 'filesystem' }. Webpack 5 then keeps its compilation cache on disk between runs, which makes rebuilds after the first one much faster. If a build ever behaves strangely after a dependency upgrade, deletenode_modules/.cache/webpackbefore you start debugging anything else.
Putting It All Together
Here's a single-file config for a React + TypeScript project that uses most of what's above. It switches behaviour on NODE_ENV instead of using webpack-merge, which is fine for smaller projects:
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
const TerserPlugin = require('terser-webpack-plugin');
const isProd = process.env.NODE_ENV === 'production';
module.exports = {
mode: isProd ? 'production' : 'development',
devtool: isProd ? 'source-map' : 'eval-source-map',
entry: {
main: './src/index.tsx',
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: isProd ? '[name].[contenthash:8].js' : '[name].js',
chunkFilename: isProd ? '[name].[contenthash:8].chunk.js' : '[name].chunk.js',
publicPath: '/',
clean: true,
},
// Speed up builds with filesystem caching
cache: {
type: 'filesystem',
},
resolve: {
extensions: ['.tsx', '.ts', '.js', '.jsx'],
alias: {
'@': path.resolve(__dirname, 'src'), // import from '@/components/...'
},
},
module: {
rules: [
// TypeScript + JSX
{
test: /\.(ts|tsx)$/,
exclude: /node_modules/,
use: 'babel-loader',
},
// CSS / SCSS
{
test: /\.(css|scss)$/i,
use: [
isProd ? MiniCssExtractPlugin.loader : 'style-loader',
'css-loader',
'sass-loader',
],
},
// Images
{
test: /\.(png|jpg|gif|svg)$/i,
type: 'asset',
parser: { dataUrlCondition: { maxSize: 8 * 1024 } },
},
],
},
plugins: [
new HtmlWebpackPlugin({
template: './public/index.html',
favicon: './public/favicon.ico',
}),
isProd && new MiniCssExtractPlugin({
filename: '[name].[contenthash:8].css',
}),
].filter(Boolean),
optimization: {
minimize: isProd,
minimizer: [new TerserPlugin(), new CssMinimizerPlugin()],
splitChunks: {
chunks: 'all',
cacheGroups: {
reactVendor: {
test: /[\\/]node_modules[\\/](react|react-dom|react-router)[\\/]/,
name: 'react-vendor',
chunks: 'all',
},
},
},
runtimeChunk: 'single',
},
devServer: {
port: 3000,
hot: true,
historyApiFallback: true,
open: true,
},
};
When I'd Reach for Webpack (and When I Wouldn't)
- Use it when you're maintaining an existing Webpack app, need Module Federation, or rely on loaders and plugins that have no equivalent elsewhere. Rewriting a working config just to switch tools rarely pays off.
- Skip it for a new React or Vue project. Vite gives you a faster dev server with far less config, and if you're in Laravel, it's already the default.
- Skip it if a framework (Next.js, Nuxt, Angular CLI) already owns the build. Extend their config through the hooks they give you instead of ejecting.
- Before shipping: make sure no
evalsource maps reach production, decide whether.mapfiles should be public or only uploaded to your error tracker, and run the bundle analyzer once to see what you're actually sending.
If you do stay on Webpack, start from the minimal config and add a loader or plugin only when an error or a measurement tells you to. Most of the scary configs I've opened are pieces somebody copied in for a problem the project no longer has.