⚙️ Webpack Configuration
The dependency graph is webpack's engine; webpack.config.js is the steering wheel. In this lesson you'll write that file from an empty folder to a real, environment-aware build — learning what each option does and, just as importantly, why it's there.
Week 3 · Day 3 (Wednesday: Webpack Basics) · Lecture 2
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Scaffold a webpack project and install the right dev dependencies
- Write a minimal
webpack.config.jswithentryandoutput - Use output filename templates like
[name]and[contenthash]for cache busting - Explain what
development,production, andnonemodes turn on - Add
resolvealiases and thedevServerfor a smooth workflow - Split configuration cleanly across environments with
webpack-merge
Estimated Time: 65 minutes
Project: Build a working config that bundles an app, serves it with live reload, and hashes filenames in production.
In This Lesson
The Recipe Book
If webpack is a professional kitchen, webpack.config.js is the recipe book that tells it exactly how to cook your app. The good news: webpack has sensible defaults, so a config can be tiny. The better news: every option you'll add answers one of the five questions from the previous lesson — where to start, where to put the result, how to process files, what extra work to do, and how hard to optimize.
The config file is a plain Node.js module — it runs in Node, not the browser, which is why you'll see require() and module.exports in it even though your app code uses ES Modules. We'll build it up one option at a time.
Project Setup
Before writing a recipe, prep the kitchen. Create a project, initialize npm, and install webpack as a dev dependency (it's a build tool, never shipped to users).
# Create and enter the project
mkdir webpack-setup-demo
cd webpack-setup-demo
# Create a package.json with defaults
npm init -y
# Install webpack and its command-line interface
npm install --save-dev webpack webpack-cli
# Scaffold the source and a config file
mkdir src
echo "console.log('Hello, webpack!');" > src/index.js
touch webpack.config.js
Your starting structure:
webpack-setup-demo/
├── node_modules/
├── src/
│ └── index.js # our entry point
├── package.json
└── webpack.config.js # the recipe book (currently empty)
📖 Why --save-dev?
Build tools like webpack, loaders, and plugins run only during development and CI — the browser receives the output, not webpack itself. Installing them under devDependencies keeps your production install lean and signals intent to your teammates.
A Minimal Config
Start with the simplest recipe that works — the equivalent of learning to boil water. Webpack only truly needs to know where to start and where to write, and it can even guess both.
// webpack.config.js
const path = require('path');
module.exports = {
// Where webpack begins walking the dependency graph:
entry: './src/index.js',
// Where and how to write the finished bundle:
output: {
filename: 'main.js',
path: path.resolve(__dirname, 'dist'), // MUST be an absolute path
},
};
Wire up an npm script so you don't type the command by hand:
// package.json
{
"scripts": {
"build": "webpack"
}
}
Run npm run build and webpack writes dist/main.js. Line by line:
path— Node's built-in module for building file paths that work on any OSentry— the root of the dependency graph webpack walksoutput.filename— the name of the emitted bundleoutput.path— an absolute directory;path.resolve(__dirname, 'dist')turns the relativedistinto a full path from your project root
⚠️ output.path must be absolute
Passing path: './dist' throws an error — webpack requires an absolute path so the output location is never ambiguous. Always wrap it in path.resolve(__dirname, ...).
Entry & Output in Depth
One entry, or many
A string entry is shorthand. Under the hood it's an object whose key becomes the bundle name — useful the moment you need more than one bundle (say, a public site and an admin panel that share nothing).
// These two are identical:
entry: './src/index.js',
entry: {
main: './src/index.js', // key "main" → main.bundle.js
},
// A true multi-entry build for separate pages:
entry: {
app: './src/app.js',
admin: './src/admin.js',
},
Filename templates & cache busting
When multiple bundles exist, hard-coding one filename won't do. Webpack fills in placeholders so each bundle is named from its entry — and, critically, from a hash of its contents:
| Template | Meaning | Example output |
|---|---|---|
[name] | The entry point's key | app.bundle.js |
[contenthash] | Hash of this file's content | app.8e0d62a8.js |
[fullhash] | Hash of the whole build | app.50b8a3e1.js |
[id] | Internal chunk id | 248.bundle.js |
output: {
filename: '[name].[contenthash].js', // app.8e0d62a8.js
path: path.resolve(__dirname, 'dist'),
clean: true, // wipe old files from dist before each build (webpack 5)
publicPath: '/', // URL prefix the browser uses to request assets
},
✅ Why [contenthash] matters
Browsers cache files by URL. If app.js never changes name, users can be stuck on a stale cached copy after you deploy a fix. A content hash means the filename changes only when the code changes — so unchanged files stay cached (fast) and changed files get a fresh URL (correct). This is cache busting, and it's the single best reason to use hashes in production.
The clean: true option is a webpack 5 built-in that replaces the old CleanWebpackPlugin — it clears dist before each build so hashed filenames don't pile up forever.
Mode & Devtool
Webpack has three modes, like preset programs on an oven. Setting mode flips on a whole bundle of built-in defaults appropriate to that goal.
module.exports = {
// 'development' — optimized for a fast, debuggable build:
// • readable, unminified output
// • fast incremental rebuilds
// • helpful error messages
// 'production' — optimized for shipping (the default):
// • minification
// • tree shaking + scope hoisting
// • side-effect flagging
// 'none' — no built-in optimizations (rarely used; for debugging webpack itself)
mode: 'production',
};
Source maps with devtool
Minified production code is unreadable when it throws an error at line 1, column 48,000. A source map maps that back to your original source so DevTools shows the real file and line. Choose the map style with devtool, trading build speed against fidelity:
module.exports = {
mode: 'development',
devtool: 'eval-source-map', // fast rebuilds, great for dev
};
// In production, prefer a full, separate map:
// devtool: 'source-map' // slower to build, most accurate
💡 Set mode from the CLI, not just the file
You can pass mode per command — webpack --mode development — which lets one config serve both environments. That's exactly what the environment-aware section below builds on.
Resolve: Cleaner Imports
The resolve section tells webpack how to find the modules your imports name. Two features earn their keep on almost every project.
module.exports = {
resolve: {
// Try these extensions so imports can omit them:
// import Button from './Button' → finds Button.jsx
extensions: ['.js', '.jsx', '.json'],
// Short aliases for deep folders:
alias: {
'@components': path.resolve(__dirname, 'src/components'),
'@utils': path.resolve(__dirname, 'src/utils'),
},
},
};
Aliases turn brittle relative paths into stable, readable ones:
// Before — fragile, breaks when you move a file:
import Button from '../../../components/Button';
// After — clear and move-proof:
import Button from '@components/Button';
💡 Tip: Keep the extensions list short. Every extra extension is another set of files webpack checks on every import — a long list quietly slows builds.
The Dev Server
Rebuilding and refreshing by hand is a productivity tax. webpack-dev-server serves your bundle from memory, rebuilds on save, and refreshes the browser — often updating the page without a full reload via Hot Module Replacement (HMR).
npm install --save-dev webpack-dev-server
module.exports = {
devServer: {
static: './dist', // serve files from here
port: 3000,
hot: true, // Hot Module Replacement
open: true, // launch the browser automatically
compress: true, // gzip responses
historyApiFallback: true, // route unknown paths to index.html (SPAs)
// Forward /api calls to a backend to sidestep CORS in dev:
proxy: [
{
context: ['/api'],
target: 'http://localhost:8080',
pathRewrite: { '^/api': '' },
},
],
},
};
// package.json
{
"scripts": {
"start": "webpack serve --mode development",
"build": "webpack --mode production"
}
}
Now npm start gives you a live-reloading dev environment, and npm run build produces the optimized artifacts for deploy.
⚠️ The dev server writes to memory, not disk
While webpack serve runs, you won't see updated files appear in dist/ — the bundle is held in memory for speed. Use npm run build when you actually need files on disk to deploy.
Environment-Aware Config
Development and production want different things: fast rebuilds and readable code vs minified, hashed, source-mapped output. There are two clean ways to serve both.
Option A: a function that reads the mode
Export a function instead of an object. Webpack calls it with the environment and CLI arguments, so you can branch on argv.mode:
// webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = (env, argv) => {
const isProduction = argv.mode === 'production';
return {
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: isProduction ? '[name].[contenthash].js' : '[name].js',
clean: true,
},
mode: isProduction ? 'production' : 'development',
devtool: isProduction ? 'source-map' : 'eval-source-map',
plugins: [
new HtmlWebpackPlugin({ template: './src/index.html' }),
],
devServer: { static: './dist', hot: true, port: 3000 },
optimization: { splitChunks: { chunks: 'all' } },
};
};
Option B: split files with webpack-merge
On larger projects, a single branchy file gets hard to read. Split it into a shared base plus per-environment overrides, then merge:
npm install --save-dev webpack-merge
// webpack.common.js — shared by every environment
const path = require('path');
module.exports = {
entry: './src/index.js',
output: {
filename: '[name].bundle.js',
path: path.resolve(__dirname, 'dist'),
clean: true,
},
};
// webpack.dev.js
const { merge } = require('webpack-merge');
const common = require('./webpack.common.js');
module.exports = merge(common, {
mode: 'development',
devtool: 'eval-source-map',
devServer: { static: './dist', hot: true, port: 3000 },
});
// webpack.prod.js
const { merge } = require('webpack-merge');
const common = require('./webpack.common.js');
module.exports = merge(common, {
mode: 'production',
devtool: 'source-map',
});
// package.json — point each script at its config
{
"scripts": {
"start": "webpack serve --config webpack.dev.js",
"build": "webpack --config webpack.prod.js"
}
}
💡 Which option should I pick?
For a small app, the single-function config (Option A) keeps everything in one place. Once the branches multiply or a teammate asks "what's different in production?", the split files (Option B) make the answer obvious at a glance. Both are idiomatic — reach for the one that keeps the config readable.
Practice & Quiz
🏋️ Exercise 1: Fix the broken output
Goal: This config throws on npm run build and would defeat browser caching even if it ran. Find and fix both problems.
const path = require('path');
module.exports = {
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: './dist',
},
mode: 'production',
};
💡 Hint
One problem makes webpack error immediately (look at output.path). The other is silent: a fixed filename never changes, so users keep the cached version after a deploy.
✅ Solution
const path = require('path');
module.exports = {
entry: './src/index.js',
output: {
filename: '[name].[contenthash].js', // cache busting
path: path.resolve(__dirname, 'dist'), // absolute path
clean: true,
},
mode: 'production',
};
output.path must be absolute, and a [contenthash] filename ensures the URL changes only when the content does.
🏋️ Exercise 2: A React-ready config (challenge)
Goal: Write an environment-aware config for a React app: JSX support via Babel, CSS Modules with Sass, images inlined under 8 KB, a dev server with HMR, and hashed filenames in production.
💡 Hint
Export a function of (env, argv). Use babel-loader with the @babel/preset-react preset for .jsx, a type: 'asset' rule with a dataUrlCondition.maxSize for images, and switch the CSS pipeline on isProduction.
✅ Solution
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = (env, argv) => {
const isProduction = argv.mode === 'production';
return {
entry: './src/index.jsx',
output: {
path: path.resolve(__dirname, 'dist'),
filename: isProduction ? '[name].[contenthash].js' : '[name].js',
clean: true,
},
module: {
rules: [
{
test: /\.jsx?$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: { presets: ['@babel/preset-env', '@babel/preset-react'] },
},
},
{
test: /\.module\.s[ac]ss$/,
use: [
isProduction ? MiniCssExtractPlugin.loader : 'style-loader',
{ loader: 'css-loader', options: { modules: true } },
'sass-loader',
],
},
{
test: /\.(png|svg|jpe?g|gif)$/i,
type: 'asset',
parser: { dataUrlCondition: { maxSize: 8 * 1024 } }, // inline if < 8KB
},
],
},
plugins: [
new HtmlWebpackPlugin({ template: './public/index.html' }),
isProduction && new MiniCssExtractPlugin({ filename: '[name].[contenthash].css' }),
].filter(Boolean),
resolve: { extensions: ['.js', '.jsx'] },
devServer: { hot: true, port: 3000, historyApiFallback: true },
optimization: { splitChunks: { chunks: 'all' } },
};
};
Loaders and plugins get a full lesson next — for now, notice the shape: one config, branching cleanly on isProduction.
🎯 Quick Quiz
Question 1: Why must output.path use path.resolve(__dirname, 'dist') instead of './dist'?
Question 2: What does the [contenthash] filename template give you?
Question 3: Setting mode: 'production' automatically enables which of these?
Best Practices & Pitfalls
✅ Do
- Keep the config minimal — add options only when you need them, and comment the non-obvious ones
- Use
[contenthash]in production filenames for reliable caching - Drive mode from the CLI (
--mode) so one config serves both environments - Extract shared config with
webpack-mergeonce the branches get busy - Add
resolve.aliasto kill fragile../../../import chains
❌ Don't
- Use a relative string for
output.path— it throws - Ship production with an unminified, unhashed bundle
- Pile a huge
resolve.extensionslist — it slows every import - Expect the dev server to write files to
dist/— it serves from memory - Duplicate the same rules across dev and prod configs instead of merging a common base
⚠️ Loaders run right-to-left
A quick preview of the next lesson, because it bites people writing their first config: in use: ['style-loader', 'css-loader'], css-loader runs first and style-loader last. Loader arrays are processed from the end toward the front.
Summary
🎉 Key Takeaways
webpack.config.jsis a Node module; a minimal one needs only entry and outputoutput.pathmust be absolute viapath.resolve(__dirname, ...)- Filename templates like
[name]and[contenthash]enable multi-bundle builds and cache busting - Mode switches on bundles of defaults; devtool controls source maps
resolve.aliascleans up imports; the dev server gives live reload and HMR- Serve dev and prod from one function or split files with
webpack-merge
📚 Additional Resources
- webpack — Configuration reference
- webpack — Getting started guide
- webpack — Production & splitting dev/prod config
- webpack — DevServer options
🚀 What's Next?
Your config can bundle JavaScript and serve it beautifully — but real apps import CSS, images, and modern syntax webpack can't read on its own. The next lesson, Loaders and Plugins, fills in the module.rules and plugins arrays we kept glossing over, and shows how they extend the build end to end.
🎉 Your kitchen is set up!
You can scaffold a project, write a real config, and serve it with live reload. Now let's teach webpack to handle every file type your app throws at it.