If you’ve recently studied a web app development course, or used a web app boilerplate, you’ve probably already encountered webpack. It’s a JavaScript module bundler which, thanks to its extensive plugin ecosystem, has become a powerful all purpose build tool for web development.

The problem with preconfigured webpack boilerplates is that you never really get the chance to see how it works. If your needs change, or something breaks, you may not have the knowledge you need to deal with it.

In this article we’ll build a basic webpack configuration from scratch, which you’re welcome to use as a boilerplate for your own projects. Most importantly, I’ll talk through the configuration line by line so nothing is left unclear.

If you’re unfamiliar with webpack, or simplify want a refresher, be sure to check out my web terms glossary before continuing. You can find the full code on my GitHub.

This article is based on my own research and experimentation and I am most indebted to the offical guides and documentation.

Goals

We’ll start by creating a basic single file webpack configuration which handles:

  • JavaScript transpilation with Babel,
  • parsing and importing CSS,
  • importing other assets, namely images and fonts,
  • generation of an HTML file that imports the JavaScript and other resources.

We’ll then create the following specialised configurations:

  • a production config which outputs an efficient minimal bundle for deployment,
  • a development config which supports hot reloading when changes are detected.

We’ll install necessary dependencies as we go and update package.json with scripts that run our builds.

Setup

To follow along, clone my boilerplate repo and check out the starter branch.

git clone https://github.com/thornecc/webpack-boilerplate-split
git checkout starter

Alternatively, create a new Node project from scratch.

mkdir project_name
cd project_name
npm init

In this case don’t forget to create a sample JavaScript source file.

mkdir src
echo "console.log('Hello World!')" >> src/index.js

A Basic webpack Config

A webpack configuration file is just a JavaScript module that exports a configuration object. Let’s start with a simple build configuration in webpack.config.js. But before we forget, let’s install webpack itself.

npm install --save-dev webpack

Entry Points and Output Bundles

Perhaps the most important options to set are the source files and the desired output bundle filename. For the first of these, we use the entry property.

module.exports = {
  entry: './src/index.js',
  // ...
}

When webpack runs, it starts at the entry point and follows any import statements it finds, building up a dependency tree as it goes. When it’s done traversing the tree (i.e. it can’t find any more imports) it packages everything up into a bundle.

Sometimes it’s useful to provide multiple entry points, for example to include third party code in the bundle, even if there’s no sensible source file in which to import it directly. This is as easy as providing an array of entry point filenames.

module.exports = {
  entry: [
    './src/index.js',
    './src/other.js'
  ],
  // ...
}

We must also specify the location of the output JavaScript bundle.

module.exports = {
  // ...
  output: {
    filename: 'bundle.js',
    path: path.join(__dirname, 'public')
  },
  // ...
}

Here we’ve used the Node builtin __dirname as well as the native path module to set the output directory to ./public. We’ve used path.join, but note that many tutorials, including the official guides, use path.resolve. The difference is described in the documentation, but note that in the present case, since __dirname is an absolute path while 'public' is relative, they are equivalent.

We can also gives names to the entry points using an object.

module.exports = {
  entry: {
    main: './src/index.js',
    other: ['./src/other.js', './src/other2.js']
  },
  output: {
    filename: '[name].bundle.js',
    path: path.join(__dirname, 'public')
  },
  // ...
}

Using this syntax, we will get one output bundle per key in the entry object. These bundles must, of course, have different filenames. To this end we use the special string '[name]' in the output filename which will be replaced by each key in entry in turn. Notice that for each named entry point we can still specify either a single filename or a list of several.

To be explicit, in the example case above, we would get two output bundles:

  • ./public/main.bundle.js, built from ./src/index.js,
  • ./public/other.bundle.js, built from ./src/other.js and ./src/other2.js.

Loaders

Next we configure the loaders, which a responsible for preprocessing files imported into webpack. We provide an array of objects, each of which takes the form

{ test: /pattern/, use: 'loader-name' }

The loader 'loader-name' will be applied to any imported files matching /pattern/. Note that use can also be set to a list of multiple loader names, which will be applied in sequence.

Let’s introduce some loaders for assets first: CSS, images and fonts. There are of course many other supported files types.

module.exports = {
  // ...
  module: {
    loaders: [
      { test: /\.css$/, use: ['style-loader', 'css-loader'] },
      { test: /\.(jpg|png|gif|svg|tiff)$/, use: 'file-loader' },
      { test: /\.(woff|woff2|eot|ttf|otf)$/, use: 'file-loader'}
    ]
  },
  // ...
}

CSS files are passed through two loaders. The style loader includes the stylesheet with a <style> tag. The CSS loader follows url() and @import directives and includes them in the webpack dependency tree as though they were JavaScript imports. Like any other webpack dependency, the CSS file itself should be included using a JavaScript import statement in an appropriate source file, e.g. ./src/index.js.

import './src/myStyles.css'

Certain assets, such as images and fonts do not require any preprocessing. They merely need to be copied into the output directory. For this we use the file loader. An import statement for such a file returns its URL relative to the site root. For example, to import an image and display it on the page:

import myImg from './src/myImg.jpg'

const createDOMImage = url => {
  // create an image in the DOM and set its URL to url
  // ...
}
createDOMImage(myImg)

In combination with the CSS loader, the file loader will also copy over any images and fonts referenced in imported stylesheets.

To install these loaders run

npm install --save-dev style-loader css-loader file-loader

For JavaScript, we use the Babel loader, which invokes Babel to perform transpilation. We will use Babel with the env preset to automatically transpile all modern JavaScript down to browser friendly ES5. Let’s install the loader, the preset and Babel itself.

npm install --save-dev babel-core babel-loader babel-preset-env

Then add the following loader to loaders.

loaders: [
  {
    test: /\.js$/,
    exclude: /node_modules/,
    use: {
      loader: 'babel-loader',
      options: {
        presets: ['env']
      }
    }
  },
  // ...
]

We use an extra option here, exclude, to tell webpack not to apply the Babel loader to any JavaScript files inside ./node_modules. Finally note the extended use syntax which specifies not only the loader name but also options to pass to it.

Alternatively, we could use the shorter syntax

{ test: /\.js$/, exclude: /node_modules/, use: 'babel-loader' }

and place the options in .babelrc,

{
  "presets": [
    "env"
  ]
}

This has the advantage that running babel independently of webpack and the babel loader will still use the same settings. On the other hand it’s quite useful to keep all build configuration inside the webpack configuration.

NOTE: If you’re reading this from the future and are running Babel 7, the syntax will be a bit different. In that case you should refer to the presets as @babel/preset-env and '@babel/preset-react both in the .babelrc and in the npm install command.

Plugins

Finally we specify our plugins, which handle a few more general tasks necessary for our build. Plugins are just JavaScript modules imported into the webpack configuration file with Node require() calls.

We’ll just use two for now:

You can find many more in the official documentation or the awesome webpack plugin list.

const CleanWebpackPlugin = require('clean-webpack-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')

To use each one we create a new instance of it with the new keyword, passing in any relevant options, and place this object in the plugins array of the webpack configuration. It will look something like this.

module.exports = {
  // ...
  plugins: [
    new CleanWebpackPlugin(['public']),
    new HtmlWebpackPlugin({
      title: 'Group To-Do',
      inject: 'body'
    }),
    // ...
  ],
  // ...
}

We use CleanWebpackPlugin to erase the output of the previous build before each run. Of course, this means we can’t just put an index.html file we wrote outselves in ./public. Instead we use HtmlWebpackPlugin to generate it for us.

We pass just two options to it: title sets the inner HTML of the page’s <title> tag. And inject: body means the script tags will be inserted into the <body> tag rather than <head>. This ensures the page is loaded before our JavaScript is run.

A major advantage of HtmlWebpackPlugin is that if we later change the JavaScript bundle’s name (or if we do something more advanced like giving it an automatically generated hash based name), then we don’t have to update the <script> tag by hand. The plugin does this for us.

Of course we might also want to customise the generated HTML. It is possible to provide a template file to the plugin via the template option (unsurprisingly). The official documentation has a page on templates if you want to learn more about this. On the other hand if building a React app for example, there might be no need to customise the HTML because everything will be rendered by React anyway.

Source Maps

One problem with bundling our JavaScript is that it makes debugging harder: when we get errors in the console they refer to line numbers in the bundle which are useless, especially for larger projects where the bundle is many thousands of lines.

This is where source maps come in. This provides a mapping between positions in the bundle and positions in the original source files. Needless to say, we can generate these with webpack!

In fact we don’t even need a plugin. We just set a value for the devtool property of the configuration object. There are a number of options to choose from here involving tradeoffs between the time taken to generate them as well as the detail of the mappings. There is a very useful comparison table in the webpack devtool documentation.

When we set up separate production and development builds below, I’ll attempt to make sane recommendations of a good setting for devtool in each case.

The Whole Configuration

// webpack.config.js
const path = require('path')
const CleanWebpackPlugin = require('clean-webpack-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
  entry: {
    app: './src/index.js'
  },
  output: {
    filename: '[name].bundle.js',
    path: path.join(__dirname, 'public')
  },
  module: {
    loaders: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['env']
          }
        }
      },
      { test: /\.css$/, use: ['style-loader', 'css-loader'] },
      { test: /\.(jpg|png|gif|svg|tiff)$/, use: 'file-loader' },
      { test: /\.(woff|woff2|eot|ttf|otf)$/, use: 'file-loader'}
    ]
  },
  plugins: [
    new CleanWebpackPlugin(['public']),
    new HtmlWebpackPlugin({
      title: 'Group To-Do',
      inject: 'body'
    })
  ]
}

This is the file webpack.config.js found on the single branch. Finally, let’s add a build script to package.json. Add the following key value pair to the "scripts" property.

{
  "scripts": {
    "start": "webpack --watch --config webpack.config.js",
    "build": "webpack --config webpack.config.js"
  }
}

Now npm run start will start a continuous webpack process that rebuilds when changes are detected, whereas npm run build will run a one off build.

The Production Configuration

Now let’s specialise the configuration for production use by enabling minification and tree shaking.

Minification is the process of squeezing JavaScript down as small as possible, by removing comments and whitespace, and indeed removing as many characters as possible without changing functionality. For example, giving variables extremely short but non-descriptive names. Fortunately debugging is still possibly even with a minified production bundle thanks to the magic of source maps!

Tree shaking also reduces bundle size by ensuring unused code doesn’t make it into the final bundle wherever possible. For example suppose a JavaScript file imports a big third party library but only uses a fraction of its functionality. Without tree shaking, all that extra code would end up in the bundle – lots of dead unwanted leaves hanging on the tree. But when we shake it, the dead leaves fall off leaving behind a small light bundle with only the code that’s actually used.

There is a single plugin that will do all of this for us: UglifyJSPlugin. It can be imported and enabled like so:

const UglifyJSPlugin = require('uglifyjs-webpack-plugin')

module.exports = {
  // ...
  plugins: [
    new UglifyJSPlugin({
      sourceMap: true
    }),
    // ...
  ],
  // ...
}

The one option we pass in, sourceMap: true ensures that the source maps generated account for the minification as well as the bundling itself.

Let’s quickly cover one other useful plugin: webpack.DefinePlugin. It allows us to define constants which will be available in our application JavaScript.

For the production build we’ll define process.env.NODE_ENV=production.

plugins: [
  // ...
  new webpack.DefinePlugin({
    'process.env.NODE_ENV': JSON.stringify('production')
  }),
  // ...
]

We could then write production/development specific code by testing this value. For example,

// src/index.js
if (process.env.NODE_ENV !== 'production') {
  // do some debugging, logging or other non production mode stuff
}

Note that some third party libraries such as React respect this setting as well.

Note also that, as per the plugin documentation, what the plugin really does is directly substitute occurences of the given key for the given value during bundling. This allows conditionals such as the one above to be evaluated statically (i.e. before runtime), so that unwanted non production code can be easily removed by tree shaking.

Source Mapping

Finally, remember that we need to set up source maps. For production use let’s use

devtool: 'source-map'

This is slow to build and rebuild, but this doesn’t matter for production builds (because they are run relatively infrequently). In return we get detailed source maps which map from the bundle back to the correct source file, line number and even column position within the line. The source maps are stored in separate files (e.g. ./public/app.bundle.js.map) so they don’t bloat the bundle and slown down page loads for end users.

Note: the webpack documentation recommends source maps are not made available to regular users at all when deploying a site publicly. This is out of the scope of this article, but I might discuss it further in my future guide on deployment.

Modular Configuration

Now we could copy webpack.config.js to webpack.prod.js and apply these new changes to that file. But we’d later have to do the same for webpack.dev.js and that would be code duplication, which is never a good thing. Fortunately, there is a very helpful tool called webpack merge, which will merge two webpack configurations for us. Let’s start by installing it.

npm install --save-dev webpack-merge

So let’s instead rename webpack.config.js to webpack.common.js.

mv webpack.config.js webpack.common.js

Then our production configuration will look something like this:

// webpack.prod.js
const webpack = require('webpack')
const UglifyJSPlugin = require('uglifyjs-webpack-plugin')
const merge = require('webpack-merge')
const common = require('./webpack.common.js')

module.exports = merge(common, {
  devtool: 'source-map',
  plugins: [
    new UglifyJSPlugin({
      sourceMap: true
    }),
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify('production')
    })
  ]
})

The second configuration takes precedence, so any values we set in the new configuration will overide those in the common one. In cases where values are arrays (or objects) as in the case of plugins, the two arrays (or objects) will be merged.

Finally let’s update the build script in package.json to run the production build.

{
  "scripts": {
    "build": "webpack --config webpack.prod.js"
  }
}

Development Configuration

In development we don’t care about minification or tree shaking so we won’t use UglifyJS. What we do want is hot reloading. This means that changes to source files are automatically reflected in the browser, without having to refresh the page. For this we use the webpack development server.

npm install --save-dev webpack-dev-server

It is configured in the same file as all other aspects of the webpack build process using a property called devServer.

devServer: {
  contentBase: common.output.path,
  compress: true,
  port: 3000,
  hot: true
}

We enable gzip compression and serve files from port 3000. Static files are served from contentBase, so we set it to the webpack output directory from the common configuration.

To enable hot reloading itself, we set hot: true, but that’s not all. We also need the webpack.HotModuleReplacementPlugin plugin. When changes are detected and hot reloading is triggered, useful information is logged to the browser console. However, by default, the name of the reloaded module is a number, meaningless to us though not, of course, to webpack!

Now webpack.NamedModulesPlugin comes to the rescue. It ensures that reloaded modules are instead named after the corresponding source file.

For basic use these plugins do not require further configuration, so enabling them is as easy as adding them to the plugins list.

plugins: [
  new webpack.NamedModulesPlugin(),
  new webpack.HotModuleReplacementPlugin()
]

Some changes to the source code itself are also required to respond to changes. Without the page’s co-operation, there is no way to apply updates without refreshing the page. So in the main JavaScript entry point, add

// ./src/index.js
if (module.hot)
  module.hot.accept()

Note that in production mode, module.hot will not be set and tree shaking will ensure this code does not make it into the bundle.

There’s certainly more to be said about hot module reloading, but I don’t want to spend longer on it for now. When I write my follow up guide on webpack for React apps, I’ll talk some more about it. For now, if you’d like to learn more, a good place to start is the HMR guide on the webpack site.

Source Maps

For development use, we want faster source map generation so it doesn’t hold up rebuilds and reloads triggered by code changes. We choose to use cheap-module-eval-source-map. This is not too slow to build and quick to regenerate after changes. It still gives the source filename and line number, merely sacrificing column position for the speed boost. These source maps are included inline in the bundle and are thus not suitable for production use.

Development Configuration: Entire File

The development configuration will then look something like this.

const merge = require('webpack-merge')
const common = require('./webpack.common.js')

module.exports = merge(common, {
  devtool: 'cheap-module-eval-source-map',
  devServer: {
    contentBase: common.output.path,
    compress: true,
    port: 3000,
    hot: true
  },
  plugins: [
    new webpack.NamedModulesPlugin(),
    new webpack.HotModuleReplacementPlugin()
  ]
})

Finally let’s update the scripts in package.json to run the development server.

{
  "scripts": {
    "build": "webpack --config webpack.prod.js",
    "start": "webpack-dev-server --config webpack.dev.js"
  }
}

Now it’s possible to start the dev server with

npm start

or npm run start if you prefer. Try saving some changes to the JavaScript source with this running and watch the browser window to check that hot reloading is working. And feel free to compare your code to the split branch of my GitHub repo.

And it’s over…

That’s a complete (albeit simple) webpack configuration from scratch. It can be used right away to bundle vanilla JavaScript (or jQuery) projects. In an upcoming post I’ll discuss what configuration changes are necessary to add React to the project. In fact, I hope to write a number of posts on enhancements to this configuration, such as how to automatically cache a site for offline viewing with a single plugin.

If you enjoyed this post, feel free to share a link to it. If you have any feedback on this post, or requests for future content and tutorials, please do email me.