Skip to main content

⚙️ 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.js with entry and output
  • Use output filename templates like [name] and [contenthash] for cache busting
  • Explain what development, production, and none modes turn on
  • Add resolve aliases and the devServer for 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.

graph TD A[webpack.config.js] --> B[entry: where to start] A --> C[output: where to write] A --> D[mode: how to optimize] A --> E[module.rules: how to read files] A --> F[plugins: extra build work] A --> G[resolve: how to find imports] A --> H[devServer: local workflow]

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 OS
  • entry — the root of the dependency graph webpack walks
  • output.filename — the name of the emitted bundle
  • output.path — an absolute directory; path.resolve(__dirname, 'dist') turns the relative dist into 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:

TemplateMeaningExample output
[name]The entry point's keyapp.bundle.js
[contenthash]Hash of this file's contentapp.8e0d62a8.js
[fullhash]Hash of the whole buildapp.50b8a3e1.js
[id]Internal chunk id248.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-merge once the branches get busy
  • Add resolve.alias to 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.extensions list — 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.js is a Node module; a minimal one needs only entry and output
  • output.path must be absolute via path.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.alias cleans 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

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