Bundling is the core of the modern web today. Modern coding styles and architecture standards make it the only way to ensure performant and fully encompassing output files. There are multiple options for achieving this, we’ve even discussed a few, but Webpack is always a top option. Much of that is due to it being strong and versatile. On the other end of that though, it can be pretty confusing for most to configure.

So today that’s what we’ll be looking into. We’ll go over some core principles in building your Webpack config file, what a basic setup looks like, and some good things to keep in mind when extending. Let’s get coding!

Getting Started

To get going let’s break down what we’ll actually be building. Keeping things simple and modern, we’ll be spinning up a TypeScript React application. This build will have three environment setups: local, development, and production. Local will be for developing on our machine(s), development will be to prepare a build suitable for a hosted dev environment, and production will be for our build that gets deployed to production and staging servers.

Our Configuration

Structure

As mentioned, we’ll be having a setup of three different configs. Local, development, and production. To properly enable us to do that, and not be repetitive, we’ll set these files up something similar to the following below.

├─ project/
  ├── config/
    ├── common.js
    ├── development.js
    ├── local.js
    ├── production.js

The bulk of the configuration we’ll be writing will live in common.js. All our other config files will extend upon it and include any specific things they’ll need.

Common.js

Code Sample
// shared config (dev and prod)
const webpack = require('webpack');
const { resolve } = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");
const Dotenv = require('dotenv-webpack');

const config = {
  mode: 'development',
  resolve: {
    extensions: ['.ts', '.tsx', '.js', '.jsx', '.scss'],
    alias: {
      '~': resolve(__dirname, '../../src/client'),
      components: resolve(__dirname, '../../src/client/components'),
      constants: resolve(__dirname, '../../src/client/constants'),
      types: resolve(__dirname, '../../src/client/@types'),
      store: resolve(__dirname, '../../src/client/store'),
      utils: resolve(__dirname, '../../src/client/utils')
    }
  },
  context: resolve(__dirname, '../../src'),
  module: {
    rules: [
      {
        test: /\.js$/,
        use: ['babel-loader', 'source-map-loader'],
        exclude: /node_modules/
      },
      {
        test: /\.tsx?$/,
        loader: 'ts-loader',
        exclude: /node_modules/,
        options: {
          configFile: resolve(__dirname, '../../tsconfig.client.json')
        }
      },
      {
        test: /\.css$/,
        use: [
          MiniCssExtractPlugin.loader,
          { loader: 'css-loader', options: { importLoaders: 1, url: false } },
          {
            loader: `postcss-loader`,
            options: {
              options: {}
            }
          }
        ]
      },
      {
        test: /\.(jpe?g|png|gif|svg)$/i,
        rules: [
          {
            loader: 'file-loader',
            options: {
              digest: 'hex',
              hash: 'sha512',
              name: 'img/[fullhash].[ext]',
            },
          },
          {
            loader: 'image-webpack-loader',
            options: {
              bypassOnDebug: true, // webpack@1.x
              disable: true, // webpack@2.x and newer
              optipng: {
                optimizationLevel: 7
              },
              gifsicle: {
                interlaced: false
              }
            },
          }
        ]
      }
    ]
  },
  plugins: [
    new Dotenv({
      systemvars: true
    }),
    new webpack.DefinePlugin({
      'process.env.API_KEY': "development"
    }),
    new HtmlWebpackPlugin({
      template: resolve(__dirname, '../../src/index.html')
    }),
    new MiniCssExtractPlugin({
      filename: '[name].css',
      chunkFilename: '[id].css'
    })
  ],
  optimization: {
    minimizer: [new CssMinimizerPlugin({})],
    moduleIds: 'named',
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        commons: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          chunks: 'all',
          minChunks: 3,
        },
        vendor: {
          test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/,
          name: 'vendors',
          chunks: 'all',
          minChunks: 2,
        },
      },
    }
  },
  performance: {
    hints: false
  },
  target: 'web'
};

module.exports = config;

In the code sample above, you’ll see what your common.js file would look like. Now let’s do a breakdown!

  • Mode
    • Specifies which type of build we’ll be doing, i.e. development vs production
  • Resolve
    • Indicates options for handling module requests
    • Extensions represent the files to look for
    • Aliases are specified to highlight special names in import statements, and how to direct them
  • Context
    • The home directory specification. Everything is resolved from this path
  • Module
    • Indicates how you’d like Webpack to read modules
    • Rules
      • Outlines how you’d like to handle modules that match a certain pattern
      • Test: indicates the file types to look for
      • Use: indicates which loaders to use with files that match; options are specific to a loader and allow for more individual configuration options
      • Exclude: indicates what should be ignored
  • Plugins
    • Used to provide additional logic and capabilities based on your needs
  • Optimization
    • Indicates performance-oriented options

Local Config

Code Sample
// local config
const { resolve } = require('path');
const { merge } = require('webpack-merge');
const webpack = require('webpack');
const commonConfig = require('./common');

const localConfig = merge(commonConfig, {
  entry: [
    'react-hot-loader/patch', // activate HMR for React
    'webpack-dev-server/client?http://localhost:3000', // bundle the client for webpack-dev-server and connect to the provided endpoint
    'webpack/hot/only-dev-server', // bundle the client for hot reloading, only- means to only hot reload for successful updates
    resolve(__dirname, '../../src/index.tsx') // the entry point of our app
  ],
  output: {
    filename: 'js/bundle.[name].[chunkhash:3].[id].min.js',
    path: resolve(__dirname, '../../public'),
    publicPath: '/',
    chunkFilename: 'js/bundle.[name].[chunkhash:5].chunk.js',
    sourceMapFilename: '[name].[chunkhash:8].[hash:8].map',
  },
  devServer: {
    hot: true, // enable HMR on the server,
    port: 3000,
    historyApiFallback: true,
    headers: {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
      'Access-Control-Allow-Headers': 'X-Requested-With, content-type, Authorization'
    }
  },
  devtool: false,
  plugins: [
    new webpack.HotModuleReplacementPlugin(), // enable HMR globally
    new webpack.SourceMapDevToolPlugin({
      filename: '[file].map[query]',
      exclude: ['vendor.js'],
    })
  ]
});

module.exports = localConfig;

In the code above you’ll find what your config for local development will look like. The main difference here is that we’ve enabled HMR(Hot Module Reload) for local development.

Development Config

Code Sample
// dev config
const { merge } = require('webpack-merge');
const webpack = require('webpack');
const { resolve } = require('path');
const commonConfig = require('./common');

module.exports = env => {
  const config = merge(commonConfig, {
    entry: resolve(__dirname, '../../src/index.tsx'),
    output: {
      filename: 'js/bundle.[fullhash:10].[id].[name:10].min.js',
      path: resolve(__dirname, '../../public'),
      publicPath: '/',
      chunkFilename: 'js/bundle.[fullhash:10].[id].[name:10].chunk.js'
    },
    devtool: false,
    plugins: [
      new webpack.EnvironmentPlugin({
        APP_ENV: (env && env.APP_ENV) || 'dev',
      }),
      new webpack.SourceMapDevToolPlugin({
        filename: '[name].js.map',
        exclude: ['vendor.js'],
      })
    ],
  });

  return config;
};

Above you’ll see the config for development. Pretty straightforward extension of our common config. The main difference here is we’ve added the environment variable loading into the app, and adding source maps.

Production Config

Code Sample
const { merge } = require('webpack-merge');
const webpack = require('webpack');
const { resolve } = require('path');
const TerserPlugin = require("terser-webpack-plugin");
const commonConfig = require('./common');

module.exports = env => {
  const config = merge(clientConfig, {
    mode: 'production',
    entry: resolve(__dirname, '../../src/client/index.tsx'),
    output: {
      filename: 'js/bundle.[fullhash:10].[id].[name:10].min.js',
      path: resolve(__dirname, '../../public'),
      publicPath: '/',
      chunkFilename: 'js/[name].[fullhash:8].[id].[name:10].chunk.js'
    },
    devtool: false,
    optimization: {
      minimize: true,
      minimizer: [new TerserPlugin({
        extractComments: false,
      })],
    },
    plugins: [
      new webpack.EnvironmentPlugin({
        APP_ENV: 'prod',
      })
    ],
  });

  return config;
};

Again, above we have the config for production. The main difference here would be the additional optimization specifications.

In Closing

Once you actually break down and view the configuration code it doesn’t seem as scary. Of course, there is more to it, and can be much more complex, but it’s not anything to be scared of. Take a breath, read, comprehend, and get to configuring!