Skip to main content

🔌 Loaders and Plugins

Out of the box, webpack reads only JavaScript and JSON. Yet real apps import CSS, Sass, images, fonts, and modern syntax. Loaders teach webpack to read those file types one at a time, and plugins hook into the whole build to do broader work. Together they turn a bare bundler into a complete asset pipeline.

Week 3 · Day 3 (Wednesday: Webpack Basics) · Lecture 3

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain the difference between a loader and a plugin, and when to reach for each
  • Configure the CSS, Sass, Babel, and asset pipelines with module.rules
  • Predict loader execution order (right-to-left) and read a rule's test regex
  • Use core plugins: HtmlWebpackPlugin, MiniCssExtractPlugin, DefinePlugin
  • Handle images and fonts with webpack 5's built-in asset modules
  • Assemble a realistic, production-ready config combining loaders and plugins

Estimated Time: 65 minutes

Project: Build a config that bundles JS, styles, and images, and auto-generates the HTML.

In This Lesson

The Assembly Line

Picture a car factory. The base assembly line (vanilla webpack) can bolt together JavaScript parts, but it can't paint the body, wire the stereo, or fit the tires. For those you bring in specialized stations — and that's the split between loaders and plugins. Loaders are the workers who transform one part as it moves down the line; plugins are the managers who oversee the whole floor and can change how the finished car is shipped.

graph LR A[Source files] --> B[Loaders
transform each file] B --> C[webpack core
builds the graph] C --> D[Plugins
act on the whole build] D --> E[Bundle output]

Keep that one-line distinction in your pocket for the whole lesson: loaders operate on individual files as they enter the graph; plugins operate on the build as a whole. Everything else is detail hanging off those two roles.

What Loaders Do

Webpack natively understands only JavaScript and JSON. A loader is a transformation that turns some other kind of file into something webpack can fold into the dependency graph. Import a CSS file and, without a loader, webpack simply doesn't know what to do — it throws "You may need an appropriate loader to handle this file type."

Modern apps lean on many file types, each needing its own loader (or built-in handling):

  • CSS / Sass / Less for styling
  • Images (PNG, JPG, SVG, GIF) and fonts (WOFF, TTF)
  • TypeScript, JSX, or bleeding-edge JavaScript that needs transpiling
  • Data formats like CSV or Markdown
A CSS file passes through css-loader then style-loader into a JS module styles.css raw CSS css-loader runs first style-loader runs last JS module in the graph Loaders run right → left in the array
Loaders form a pipeline. In use: ['style-loader', 'css-loader'], css-loader processes the file first and hands its result to style-loader.

Loader Order & Anatomy

The rule that trips up every beginner: loaders in a use array run from right to left (equivalently, bottom to top). Think of it as function composition — style-loader(css-loader(file)): the innermost, rightmost loader touches the raw file first.

// ❌ Wrong order — style-loader would try to run before CSS is parsed
use: ['css-loader', 'style-loader']

// ✅ Correct — css-loader parses first, style-loader injects last
use: ['style-loader', 'css-loader']
//     ↑ runs 2nd      ↑ runs 1st

Every rule is an object. The pieces you'll use constantly:

graph TD A[A rule object] --> B["test: /\.css$/i — which files match"] A --> C["use: the loader(s) to apply"] A --> D["type: built-in asset handling"] A --> E["exclude: paths to skip, e.g. node_modules"] A --> F["include: restrict to certain paths"]

⚠️ test is a regex, not a string

test: '.js'    // ❌ matches any filename CONTAINING ".js" anywhere
test: /\.js$/  // ✅ matches filenames ENDING in ".js"

The $ anchors the match to the end. Forgetting it is a classic source of "why is this loader running on the wrong files?" bugs.

The Common Loaders

1. Styling: CSS and Sass

Two loaders team up for CSS. css-loader reads the file and resolves its @import/url() references into the module graph; style-loader then injects those styles into the page by creating <style> tags at runtime.

npm install --save-dev css-loader style-loader
module.exports = {
    module: {
        rules: [
            {
                test: /\.css$/i,
                use: ['style-loader', 'css-loader'], // right-to-left
            },
        ],
    },
};
// Now you can import CSS straight into your JavaScript:
import './styles.css';

const header = document.createElement('h1');
header.className = 'header';
header.textContent = 'Styled with webpack!';
document.body.appendChild(header);

For Sass, add sass-loader at the end of the array so it compiles .scss down to CSS before the other two run:

npm install --save-dev sass sass-loader css-loader style-loader
{
    test: /\.s[ac]ss$/i,       // matches .scss and .sass
    use: [
        'style-loader',   // 3rd: inject into the DOM
        'css-loader',     // 2nd: resolve imports/urls
        'sass-loader',    // 1st: compile Sass → CSS
    ],
}

2. Modern JavaScript: babel-loader

Babel transpiles modern syntax down to a form older browsers understand, so you can write today's JavaScript without abandoning yesterday's users. babel-loader runs Babel on each matched file as it enters the graph.

npm install --save-dev babel-loader @babel/core @babel/preset-env
{
    test: /\.js$/,
    exclude: /node_modules/,   // never transpile dependencies — slow and unnecessary
    use: {
        loader: 'babel-loader',
        options: {
            presets: ['@babel/preset-env'], // "compile down to what browsers need"
        },
    },
}
💡 Tip: Prefer a babel.config.json file for Babel options once they grow — it keeps webpack.config.js focused on bundling and lets other tools (like Jest) share the same Babel settings. You'll go deeper on Babel in the next lesson on transpilation.

Images & Fonts (Asset Modules)

In webpack 4 you needed file-loader, url-loader, or raw-loader for images and fonts. Webpack 5 built these in as asset modules — no extra package required. You pick a type instead of a loader:

typeWhat it doesReplaces
asset/resourceEmits a separate file, gives you its URLfile-loader
asset/inlineInlines the file as a base64 data URLurl-loader
asset/sourceInjects the raw file contents as a stringraw-loader
assetAuto-picks resource vs inline by file sizeurl-loader w/ limit
module.exports = {
    module: {
        rules: [
            {
                test: /\.(png|svg|jpe?g|gif)$/i,
                type: 'asset/resource',      // copy to dist, return the URL
            },
            {
                test: /\.(woff2?|eot|ttf|otf)$/i,
                type: 'asset/resource',
            },
            {
                test: /\.svg$/i,
                type: 'asset',               // inline small SVGs, emit big ones
                parser: { dataUrlCondition: { maxSize: 8 * 1024 } }, // 8KB cutoff
            },
        ],
    },
};
// Importing an image returns its final URL:
import logoUrl from './logo.png';

const img = new Image();
img.src = logoUrl;   // e.g. "/logo.8e0d62a8.png"
document.body.appendChild(img);

💡 Inline vs emit — the tradeoff

Inlining a tiny icon as a data URL saves an HTTP request. Inlining a large photo bloats your JavaScript bundle and hurts caching. The asset type with a maxSize threshold gives you the best of both automatically — small files inline, large files get their own cacheable URL.

What Plugins Do

Where a loader transforms one file, a plugin taps into webpack's compilation lifecycle and can act on the entire build — generating new files, injecting variables, extracting or minifying output, analyzing bundle size. Plugins are the managers with a view of the whole factory floor.

Two mechanical differences from loaders to burn in:

  • Plugins live in a top-level plugins: [] array, not inside module.rules
  • Plugins are instantiated with new, because most accept options
// ❌ Wrong — passing the class itself
plugins: [HtmlWebpackPlugin]

// ✅ Right — a new instance, optionally configured
plugins: [new HtmlWebpackPlugin({ title: 'My App' })]
graph TD A["Need to transform a file type?"] -->|Yes| B[Use a loader] A -->|No| C["Need to affect the whole build?"] C -->|Yes| D[Use a plugin] C -->|No| E[Vanilla webpack is enough] B --> F["babel-loader, css-loader, sass-loader"] D --> G["HtmlWebpackPlugin, DefinePlugin, MiniCssExtractPlugin"]

Essential Plugins

1. HtmlWebpackPlugin — generate the HTML

With hashed filenames like main.8e0d62a8.js, hand-writing a <script> tag is hopeless — the name changes every build. This plugin generates an HTML file and injects the correct tags automatically.

npm install --save-dev html-webpack-plugin
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
    plugins: [
        new HtmlWebpackPlugin({
            title: 'My App',
            template: './src/index.html', // your own template, optional
        }),
    ],
};
// The output index.html automatically includes:
//   <script src="main.8e0d62a8.js" defer></script>

2. MiniCssExtractPlugin — real CSS files

style-loader injects CSS via JavaScript, which is fine in development but means styles can't load in parallel or be cached separately. For production, this plugin extracts CSS into its own .css files. Note it comes with a loader that replaces style-loader in the chain.

npm install --save-dev mini-css-extract-plugin
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
    module: {
        rules: [
            {
                test: /\.css$/i,
                use: [MiniCssExtractPlugin.loader, 'css-loader'], // swap out style-loader
            },
        ],
    },
    plugins: [
        new MiniCssExtractPlugin({
            filename: '[name].[contenthash].css', // cache-busted CSS
        }),
    ],
};

3. DefinePlugin — inject build-time constants

A webpack built-in (no install) that replaces identifiers with values at build time — the standard way to feed an API URL or environment flag into your code.

const webpack = require('webpack');

module.exports = {
    plugins: [
        new webpack.DefinePlugin({
            'process.env.API_URL': JSON.stringify('https://api.example.com'),
        }),
    ],
};
// In your app, process.env.API_URL becomes the literal string at build time.

⚠️ Wrap DefinePlugin values in JSON.stringify

DefinePlugin does a raw text substitution. Without JSON.stringify, 'https://api.example.com' would be pasted as bare code (a syntax error), not as a quoted string. JSON.stringify adds the quotes for you.

A Real-World Config

Here's everything from this lesson working together — an environment-aware config that transpiles JS, handles CSS/Sass and assets, extracts CSS in production, and generates the HTML.

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.js',
        output: {
            path: path.resolve(__dirname, 'dist'),
            filename: isProduction ? '[name].[contenthash].js' : '[name].js',
            clean: true,
        },
        module: {
            rules: [
                // Modern JavaScript
                {
                    test: /\.js$/,
                    exclude: /node_modules/,
                    use: {
                        loader: 'babel-loader',
                        options: { presets: ['@babel/preset-env'] },
                    },
                },
                // Styles — extract in prod, inject in dev
                {
                    test: /\.s?css$/i,
                    use: [
                        isProduction ? MiniCssExtractPlugin.loader : 'style-loader',
                        'css-loader',
                        'sass-loader',
                    ],
                },
                // Images & fonts via built-in asset modules
                {
                    test: /\.(png|svg|jpe?g|gif|woff2?|ttf|eot)$/i,
                    type: 'asset/resource',
                },
            ],
        },
        plugins: [
            new HtmlWebpackPlugin({ template: './src/index.html' }),
            // Only extract CSS in production; filter(Boolean) drops the false in dev
            isProduction && new MiniCssExtractPlugin({
                filename: '[name].[contenthash].css',
            }),
        ].filter(Boolean),
        devServer: {
            static: './dist',
            hot: true,
            port: 3000,
        },
        devtool: isProduction ? 'source-map' : 'eval-source-map',
    };
};

Running it

$ npm run build
  asset main.8e0d62a8.js   14.2 KiB  [emitted] [immutable]
  asset main.4f1c9b2d.css   2.1 KiB  [emitted] [immutable]
  asset index.html          0.4 KiB  [emitted]
webpack compiled successfully

Practice & Quiz

🏋️ Exercise 1: Fix the Sass pipeline

Goal: This rule is meant to compile .scss, resolve imports, and inject the result — but the styles never appear and the build errors. Find both bugs.

{
    test: '.scss',
    use: ['sass-loader', 'css-loader', 'style-loader'],
}
💡 Hint

One bug is in test (it should be a regex). The other is loader order — remember loaders run right to left, and Sass must be compiled to CSS before css-loader and style-loader can do their jobs.

✅ Solution
{
    test: /\.scss$/i,
    use: ['style-loader', 'css-loader', 'sass-loader'],
}

The array is reversed: sass-loader (rightmost) compiles first, then css-loader, then style-loader injects. And test must be the anchored regex /\.scss$/i.

🏋️ Exercise 2: Loader or plugin?

Goal: For each task, decide whether it needs a loader or a plugin, and name a candidate.

  1. Compile TypeScript files to JavaScript
  2. Generate an index.html that references the hashed bundle
  3. Inject a different API URL for production vs development
  4. Turn imported PNGs into emitted files with hashed names
✅ Solution

1. Loaderts-loader (or babel-loader) transforms each .ts file. 2. PluginHtmlWebpackPlugin acts on the whole build to emit HTML. 3. PluginDefinePlugin injects build-wide constants. 4. Neither a classic loader nor plugin — webpack 5's built-in type: 'asset/resource' handles it (it replaced file-loader). The tell: file-by-file transform → loader/asset type; whole-build action → plugin.

🎯 Quick Quiz

Question 1: In use: ['style-loader', 'css-loader'], which loader processes the file first?

Question 2: What's the core difference between a loader and a plugin?

Question 3: In webpack 5, how do you handle imported images without extra packages?

Best Practices & Pitfalls

✅ Do

  • Remember loaders run right to left and order the array accordingly
  • Anchor test regexes with $ (e.g. /\.css$/i)
  • exclude: /node_modules/ from babel-loader — never transpile dependencies
  • Use webpack 5 asset modules instead of the old file/url/raw loaders
  • Extract CSS with MiniCssExtractPlugin in production, inject with style-loader in dev
  • Instantiate plugins with new

❌ Don't

  • Put loaders in the plugins array or vice versa
  • Forget JSON.stringify around DefinePlugin string values
  • Use a plain string for test — it matches far more than you expect
  • Run MiniCssExtractPlugin.loader and style-loader together in the same rule
  • Reach for a plugin when a simple loader (or asset type) does the job

✅ The mental model, one more time

Transforming a file type as it enters the graph? That's a loader (or an asset type). Doing something to the build as a whole — emitting files, injecting constants, minifying, analyzing? That's a plugin. When you're unsure which you need, ask "one file, or the whole build?"

Summary

🎉 Key Takeaways

  • Loaders transform individual files so webpack can bundle non-JS types; they live in module.rules
  • Loaders in a use array run right to left, and test is an anchored regex
  • Core loaders: css-loader + style-loader, sass-loader, babel-loader
  • Webpack 5 asset modules (type: 'asset/resource' etc.) replace file/url/raw loaders
  • Plugins act on the whole build, live in plugins: [], and are created with new
  • Essential plugins: HtmlWebpackPlugin, MiniCssExtractPlugin, DefinePlugin

📚 Additional Resources

🚀 What's Next?

You wired babel-loader into the build without really unpacking what Babel does. The next lesson, JavaScript Transpilation Concepts, zooms in on that: how modern syntax is rewritten for older browsers, what presets and polyfills are, and why transpilation sits at the heart of the modern toolchain.

🎉 Your pipeline is complete!

Loaders read every file type, plugins shape the whole build. You can now configure webpack for a real project — next we look under the hood of the transpiler that made it all modern.