Create a React app from scratch with Webpack and Babel

June 3, 2019 (updated March 19, 2022)

This article will guide you through the process of creating a React app from scratch. It is meant for developers who want a better understanding of how tools like Babel, Webpack, DevServer, React, loaders, and presets make up a modern React app. We'll try to understand how they all fit together by incrementally piecing together an application.

The article was updated in 2022 for Webpack 5 and Babel 7.


Bootstrapping your own React app from scratch can be confusing. You don't simply run React in a browser. You need to bundle the different parts of your application together and transform your code into something the browser can understand.

Luckily there are some great tools that help you with that. You've probably heard of Webpack, a popular module bundler, and Babel, a tool that compiles "next generation" JavaScript into a backwards compatible version.

You could start your project by copying boilerplate code from a blog post or a Github repository. But I would suggest you do one of the following instead:

  1. Create your own boilerplate code and try to understand each step of the configuration process.
  2. Use create-react-app, a popular tool that lets you set up a React app with just one command. You don't need to get your hands dirty with Webpack or Babel because everything is preconfigured and hidden away from you.

While create-react-app is a great tool, the purpose of this article is to create our own boilerplate.

Let's jump right in.

Initialize the project

(This article has a companion Git repository: github.com/nicoqh/react-boilerplate)

We'll start by creating a basic directory structure for our project. We need a directory for our source files which we'll call src. We also need a directory for compiled assets like JavaScript and HTML files. We'll call this directory dist.

mkdir src
mkdir dist

The next step is to create a .gitignore file with the following content:

dist
node_modules

This will instruct Git to ignore the node_modules directory and make sure we don't accidentally commit every Node module we use in our project. We also want to ignore the dist directory. Everything inside this directory will be compiled from the source files, so we don't need to add it to version control.

As with most Node-based projects, we need a package.json that lists our dependencies. Simply add an empty object for now:

{}

Let's add some code to our src directory. Create the file index.js with the following code:

// src/index.js
const greet = (name) => console.log(`Hello, ${name}`);

greet("Jon Snow");

This code uses arrow functions and template literals which are ES6 features that don't yet work in every browser. The code needs to be transpiled. This brings us to Babel.

Babel

What is Babel?

According to its website, "Babel is a toolchain that is mainly used to convert ECMAScript 2015+ code into a backwards compatible version of JavaScript in current and older browsers or environments."

In other words we need Babel to transform modern JavaScript code into something browers can understand. We also need Babel to transform other "weird" stuff, like the JSX we will use with React, into something that makes sense to browsers.

To use Babel we need the compiler core and the Babel command line:

npm install --save-dev @babel/core @babel/cli

Now that Babel is installed in our node_modules folder, let's try to run it on our source and output the result to dist:

./node_modules/.bin/babel src --out-dir dist

Did anything happen? If you look inside the dist folder you'll see that our code hasn't changed at all. This is because we haven't told Babel what to do. Out of the box, Babel simply parses our code and returns it untouched.

Note: Instead of running the Babel executable located at ./node_modules/.bin/babel, we can use a tool called npx. npx makes it easier to execute local packages. Usage: npx <command>.

Syntax transformations

For Babel to do anything useful we need to enable some plugins. Plugins are responsible for transforming our code and parsing/understanding the syntax. For example, there's a plugin called @babel/plugin-transform-arrow-functions which transforms arrow functions to plain old JavaScript functions. However, instead of installing a bunch of individual plugins (one for each transformation), Babel offers something called "presets". You can think of a preset as an array of plugins used to support a particular JavaScript language feature.

One of the official presets is called @babel/preset-env. This is a "smart" preset that allows us to use the latest JavaScript without needing to manage which specific syntax transformations or polyfills are needed by your target environments. (A "target environment" is an environment on which we want our code to run, e.g. Chrome 99.) The preset lets us specify a set of environments, and it will generate a list of plugins which it passes to Babel. With this list of plugins Babel will only transform language features that are not implemented in the browsers we target. That leads to a smaller bundle and less code to parse.

Let's install @babel/preset-env:

npm install --save-dev @babel/preset-env

Now, how do we specify our target environments?

@babel/preset-env integrates with Browserslist, a project that lets us specify target environments using queries. Babel recommends putting the queries in a .browserslistrc file.

Let's create the .browserslistrc file:

# Browsers we support

> 0.5%
last 2 versions
not dead

These queries will select the last 2 versions of every browser that is not "dead" and has a market share above 0.5%. You can read more about Browserslist's query syntax at github.com/browserslist/browserslist

Let's run Babel again using the preset @babel/preset-env:

npx babel src --out-dir dist --presets=@babel/preset-env

Check out the dist folder—our code has been transformed! (Unless your target environments support both arrow functions and template literals.)

"use strict";

var greet = function greet(name) {
  return console.log("Hello, ".concat(name));
};

greet("Jon Snow");

Based on our Browserslist queries, @babel/preset-env applied the necessary transform plugins (in this case @babel/plugin-transform-arrow-functions and @babel/plugin-transform-template-literals) to transform our code. If these language features were already supported by the target environments, Babel would have left the code untouched.

The Babel command will soon become cumbersome to manage (and to remember). Luckily Babel lets us specify our options in a configuration file.

Let's create the file babel.config.js and configure Babel to use @babel/preset-env so we don't have to specify it on the command line:

// babel.config.js
const presets = [["@babel/preset-env"]];

const plugins = [];

// Export a config object.
module.exports = { presets, plugins };

Babel will detect this file automatically. We no longer need to manually type out our presets (or other configuration values) on the command line.

Let's try it:

npx babel src --out-dir dist

This should yield the same result as earlier.

It would be nice if we could see what plugins and presets were actually applied. preset-env takes a debug option which, if set to true, will instruct Babel to output the targets and plugins it uses during compilation. Update your babel.config.js to look like this (view commit):

// babel.config.js
const presets = [
  [
    "@babel/preset-env",
    {
      // Pass a config object to the preset
      debug: true, // Output the targets/plugins used when compiling
    },
  ],
];

const plugins = [];

// Export a config object.
module.exports = { presets, plugins };

Run Babel again and the output will be similar to this:

// npx babel src --out-dir dist

@babel/preset-env: `DEBUG` option

Using targets:
{
  "android": "98",
  "chrome": "96",
  "edge": "97",
  "firefox": "96",
  // ...
}

Using modules transform: auto

Using plugins:
  proposal-class-static-block { ie, ios, safari, samsung }
  proposal-private-property-in-object { ie, ios < 15, safari < 15, samsung }
  proposal-class-properties { ie, ios < 15 }

Polyfills

Certain modern language features can be "transformed" into older syntax. This is the case with the arrow function above; it can be replaced by a plain old JavaScript function. Other functionality needs to be added to the runtime as "polyfills". For this we use a library called core-js.

core-js is a standard library for JavaScript that includes polyfills for a wide array of JavaScript features. The library lets us use features like promises and symbols in browsers that don't yet support them. By including a polyfill for a language feature, we can use the feature as if it were natively supported by the browser.

Install core-js:

npm install --save core-js

Import core-js at the top of your src/index.js:

// src/index.js
import "core-js/stable"; // Loads all language features

// ... the rest of our code

We're now including every polyfill that is offered by core-js. Is this necessary? We don't want to polyfill features that are already supported by our target environments. As mentioned earlier, @babel/preset-env uses Browserslist to include only the transformation plugins we need. @babel/preset-env can also decide what polyfills to include from core-js using the preset's useBuiltIns option.

The useBuiltins option takes one of the following values:

  • 'entry': This will enable a plugin that transforms the import of core-js (import 'core-js/stable', like we did above), to imports of individual core-js polyfills. Our target environments will determine which polyfills to import. It doesn't matter if our app uses the language feature or not; as long as the feature is missing from one of our target environments, the polyfill is loaded.
  • 'usage': This option will add individual polyfill imports whenever a language feature is actually used in our source files. We don't need to manually import anything. Whenever we use a feature that isn't supported by one of our target environments, a polyfill is imported in the file that needs it.

Let's use the 'usage' option so we don't have to worry about importing polyfills.

Update your babel.config.js (view commit):

// babel.config.js
const presets = [
  [
    "@babel/preset-env",
    {
      // Pass a config object to the preset
      debug: true, // Output the targets/plugins used when compiling

      // NEW CODE:

      // Configure how @babel/preset-env handles polyfills from core-js.
      // https://babeljs.io/docs/en/babel-preset-env
      useBuiltIns: "usage",

      // Specify the core-js version. Must match the version in package.json
      corejs: 3,

      // Specify which environments we support/target. We have chosen to specify
      // targets in .browserslistrc, so there is no need to do it here.
      // targets: "",

      // END NEW CODE
    },
  ],
];

const plugins = [];

// Export a config object.
module.exports = { presets, plugins };

We also need to tell Babel which core-js version we're using. @babel/preset-env supports both version 2 and 3. We have specified the version by setting corejs: 3. This should match the version specified in your package.json.

Because we have set useBuiltIns: 'usage' we can remove the import 'core-js/stable' statement from src/index.js. As you may remember, the 'usage' option takes care of importing any necessary polyfills.

Our current configuration will have the following effect on our code:

// Before transformation
var a = new Promise();

// After transformation (if the environment doesn't support promises):
import "core-js/modules/es.promise"; // The promise polyfill is imported ...
var a = new Promise(); // ... so this will work.

(Side note: Are you wondering why we're not using @babel/polyfill? This package has been deprecated in favor of importing core-js like we did above.)

That's it for Babel. Let's continue to Webpack.

Webpack

What is Webpack?

According to its website, Webpack is "a static module bundler for modern JavaScript applications". Webpack creates a graph of every module our app uses (JavaScript files, React components, images, CSS files etc.), and generates one or more bundles. It's not uncommon to generate one bundle that contains all the modules that make up an application.

Let's install Webpack and its command line tool (CLI):

npm install --save-dev webpack webpack-cli

Create the file webpack.config.js and add the following content (view commit):

// webpack.config.js
const path = require("path");

// We'll refer to our source and dist paths frequently, so let's store them here
const PATH_SOURCE = path.join(__dirname, "./src");
const PATH_DIST = path.join(__dirname, "./dist");

// Export a configuration object
module.exports = {
  // Tell Webpack to do some optimizations for our environment (development
  // or production). Webpack will enable certain plugins and set
  // `process.env.NODE_ENV` according to the environment we specify.
  // https://webpack.js.org/configuration/mode
  mode: "development",

  // The point or points to enter the application. This is where Webpack will
  // start. We generally have one entry point per HTML page. For single-page
  // applications, this means one entry point. For traditional multi-page apps,
  // we may have multiple entry points.
  // https://webpack.js.org/concepts#entry
  entry: [path.join(PATH_SOURCE, "./index.js")],

  // Tell Webpack where to emit the bundles it creates and how to name them.
  // https://webpack.js.org/concepts#output
  // https://webpack.js.org/configuration/output
  // https://webpack.js.org/configuration/output#outputFilename
  output: {
    path: PATH_DIST,
    filename: "js/[name].[contenthash].js",

    // The public URL of the output dir when referenced in a browser.
    // This value is prefixed to every URL created by the runtime or loaders.
    // It's empty by default, which creates URLs like 'bundle.js' and results
    // in 404s if they're requested from a nested URL like /articles/1
    // https://webpack.js.org/configuration/output/#outputpublicpath
    publicPath: "/",
  },
};

This is a pretty basic Webpack configuration file. You should read through the comments to get a sense of its structure.

Our entry point is ./src/index.js and our compiled bundle will be emitted to ./dist/js/[name].[contenthash].js. Webpack will substitute [name] with the entry name, which is main by default. [contenthash] will be replaced with a hash of the module's (the file's) content. This is great for HTTP caching. We can tell browsers to cache JavaScript files aggressively. Whenever we re-build the app with updated code, the bundle name (the hash) changes as well. This will break the browser's cache and force it to re-download the bundle.

Because we only have one entry point and only create one bundle, the output file could have a static name, like bundle.js. But we'll keep it dynamic in case we need multiple bundles later, and because [contenthash] is useful for caching.

Before we run Webpack with our newly created config, let's create an npm script so we don't need to type out the whole command.

Open your package.json and add the "scripts" section (view commit):

{
  "scripts": {
    "dev": "webpack --config webpack.config.js"
  },
  "devDependencies": {
    // ... the rest of package.json

Now we can simply run:

npm run dev

If we take a look in our dist directory we'll find a js directory with a file named main.[some-hash].js. This is our bundle. It contains a lot of Webpack-specific code which we don't need to care about. Somewhere at the bottom you'll also see our application code.

A new bundle will be generated every time we change the source code and run the npm script npm run dev. Eventually the directory will be littered with old bundles. We'll deal with this nuisance later. For now you can simply delete the dist folder regularly.

If you look closely in dist/js/main.[some-hash].js you'll notice that our code hasn't been transformed and that no polyfills have been loaded. This is because we haven't told Webpack to use Babel yet. We only ran the Webpack command, with no mention of Babel. We'll fix that soon, but first we'll create a module to see how Webpack handles so-called "bundling".

Create a file named sum.js in the source directory with the following contents:

// src/sum.js
const sum = (a, b) => a + b;

export default sum;

This is our first module. It consists of an arrow function that returns the sum of two numbers, a and b. This module can be imported from anywhere.

Let's use sum in src/index.js:

// src/index.js
import sum from "./sum";

console.log(sum(2, 4)); // Output: 6

Run Webpack again.

npm run dev

Just like before, our bundle ends up in dist/js/main.[some-hash].js. It contains all our code, including the imported sum module. Let's run our bundle with Node to see if it works:

node dist/js/main.[some-hash].js

You should see 6 on the command line.

Building the application with Webpack works as expected, but we still need to include Babel in our build process. This brings us to a Webpack concept called "loaders".

Webpack Loaders

We have already configured Babel to run transformations on our code and import any necessary polyfills. But, at the moment, we're not running Babel, we're only running Webpack. Our next task is to tell Webpack how to include Babel in its build process. This is achieved with "loaders".

Loaders are used to tell Webpack how to treat the different modules that we import throughout our app. Using loaders we can tell Webpack what to do when we import sum from './sum', import './styles/main.scss' and import logo from './logo.png'.

For example, using a loader we can instruct Webpack to run .scss files through a Sass compiler.

In other words, loaders are transformations that are applied on the source code of a module. They allow us to pre-process files as we import or "load" them.

We want Webpack to run Babel on all our JavaScript modules. Whenever we import a file that ends in .js we want Webpack to use a Babel "loader". The Babel loader will run Babel on the imported code, and Babel will transform it according to our Babel configuration.

There's a loader conveniently called babel-loader which we'll install:

npm install --save-dev babel-loader

Next we'll update our Webpack configuration. We'll create a new section called modules in which we'll specify how our modules should be treated by Webpack. In this section we will add some rules which tell Webpack how and when to use the loaders.

Update your webpack.config.js to reflect the following changes (view commit):

// webpack.config.js
  // ...
  output: {
    path: PATH_DIST,
    filename: 'js/[name].[hash].js',
  },

  // NEW CODE:

  // Determine how the different types of modules will be treated.
  // https://webpack.js.org/configuration/module
  // https://webpack.js.org/concepts#loaders
  module: {
    rules: [
      {
        test: /\.js$/, // Apply this rule to files ending in .js
        exclude: /node_modules/, // Don't apply to files residing in node_modules
        use: { // Use the following loader and options
          loader: "babel-loader",
          // We can pass options to both babel-loader and Babel. This option object
          // will replace babel.config.js
          options: {
            presets: [
              ["@babel/preset-env", {
                debug: true, // Output the targets/plugins used when compiling

                // Configure how @babel/preset-env handles polyfills from core-js.
                // https://babeljs.io/docs/en/babel-preset-env
                useBuiltIns: "usage",

                // Specify the core-js version. Must match the version in package.json
                corejs: 3,

                // Specify which environments we support/target for our project.
                // (We have chosen to specify targets in .browserslistrc, so there
                // is no need to do it here.)
                // targets: "",
              }],
            ],
          },
        }
      }
    ],
  },
  // END NEW CODE

};

You should read the comments as they explain most of what's going on.

Notice the options object that we pass to babel-loader. This object has been copied directly from our babel.config.js. Instead of having Babel read babel.config.js, we can pass our options through Webpack. As you'll see later, it's very convenient to put our Babel configuration inside our Webpack configuration.

You can safely delete babel.config.js.

Test the new Webpack config by running the npm script we created earlier:

npm run dev

In summary, Webpack bundles our code. It also uses the "loader" babel-loader to run Babel on any file (module) that ends in .js. Babel transforms our code to a backward-compatible version.

Development vs production

Eventually we want to differentiate development builds from production builds.

Our Webpack config currently exports a configuration object. If we instead export a function, Webpack will invoke it with an environment as the first argument. We can use this argument to include or exclude configuration options based on whether we're building for production or development.

Consider this example:

// Exporting an object from webpack.config.js:
// (This is what we're currently doing.)
module.exports = {
  // Our Webpack config object.
};

// If we export a function, it will be passed two parameters, the first
// of which is the webpack command line environment option `--env`.
// `webpack --env a=b` sets env.a = 'b'
// `webpack --env environment=production` sets env.environment = 'production'
// https://webpack.js.org/configuration/configuration-types/#exporting-a-function
module.exports = (env) => {
  // Use the `env` argument to create some helpful constants.
  const environment = env.environment;
  const isProduction = environment === "production";
  const isDevelopment = environment === "development";

  return {
    // Our Webpack config object.
    // We now have access to the constants `environment`,
    // `isProduction` and `isDevelopment`.
    mode: environment,
  };
};

Let's incorporate this into our Webpack config. Our new webpack.config.js should look like this (view commit):

// webpack.config.js
const path = require("path");

// We'll refer to our source and dist paths frequently, so let's store them here
const PATH_SOURCE = path.join(__dirname, "./src");
const PATH_DIST = path.join(__dirname, "./dist");

// If we export a function, it will be passed two parameters, the first
// of which is the webpack command line environment option `--env`.
// `webpack --env.a = b` sets env.a = 'b'
// `webpack --env.production` sets env.production = true
// https://webpack.js.org/configuration/configuration-types/#exporting-a-function
module.exports = (env) => {
  const environment = env.environment;
  const isProduction = environment === "production";
  const isDevelopment = environment === "development";

  return {
    // Tell Webpack to do some optimizations for our environment (development
    // or production). Webpack will enable certain plugins and set
    // `process.env.NODE_ENV` according to the environment we specify.
    // https://webpack.js.org/configuration/mode
    mode: environment,

    // The point or points to enter the application. This is where Webpack will
    // start. We generally have one entry point per HTML page. For single-page
    // applications, this means one entry point. For traditional multi-page apps,
    // we may have multiple entry points.
    // https://webpack.js.org/concepts#entry
    entry: [path.join(PATH_SOURCE, "./index.js")],

    // Tell Webpack where to emit the bundles it creates and how to name them.
    // https://webpack.js.org/concepts#output
    // https://webpack.js.org/configuration/output
    // https://webpack.js.org/configuration/output#outputFilename
    output: {
      path: PATH_DIST,
      filename: "js/[name].[contenthash].js",
    },

    // Determine how the different types of modules will be treated.
    // https://webpack.js.org/configuration/module
    // https://webpack.js.org/concepts#loaders
    module: {
      rules: [
        {
          test: /\.js$/, // Apply this rule to files ending in .js
          exclude: /node_modules/, // Don't apply to files residing in node_modules
          use: {
            // Use the following loader and options
            loader: "babel-loader",
            // We can pass options to both babel-loader and Babel. This option object
            // will replace babel.config.js
            options: {
              presets: [
                [
                  "@babel/preset-env",
                  {
                    debug: true, // Output the targets/plugins used when compiling

                    // Configure how @babel/preset-env handles polyfills from core-js.
                    // https://babeljs.io/docs/en/babel-preset-env
                    useBuiltIns: "usage",

                    // Specify the core-js version. Must match the version in package.json
                    corejs: 3,

                    // Specify which environments we support/target for our project.
                    // (We have chosen to specify targets in .browserslistrc, so there
                    // is no need to do it here.)
                    // targets: "",
                  },
                ],
              ],
            },
          },
        },
      ],
    },
  };
};

These changes will come in handy when we add more options and loaders later. But how do we pass the environment to Webpack? With the --env option:

webpack --env environment=production --config webpack.config.js

We can now create two different npm scripts, one for each environment. Change the "scripts" section of your package.json to look like this:

// package.json
{
  "scripts": {
    "build": "webpack --env.environment=production --config webpack.config.js",
    "dev": "webpack --env.environment=development --config webpack.config.js"
  },
  // ...

To create a production build we simply run:

npm run build

And for development builds:

npm run dev

Adding index.html (HtmlWebpackPlugin)

This is our current directory structure:

├── src
│   ├── index.js
│   └── sum.js
├── dist
│   └── js
│       └── main.[some-hash].js
├── package.json
├── package-lock.json
├── webpack.config.js
└── .browserslistrc

Eventually we want to deploy the dist directory to a server, but first we need an index.html which will serve as the entry point to our web application.

The HTML file should import the bundle, like this:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Boilerplate!</title>
  </head>
  <body>
    <script src="/js/main.c9eb7e60a479f1a2d6bc.js"></script>
  </body>
</html>

However, instead of putting a static HTML file inside dist, we want Webpack to generate the file automatically. There are a few reasons for this:

  • We don't want to hardcode the bundle's file name which changes frequently.
  • We've told Git to ignore the dist directory (using .gitignore), so everything we put inside it will be lost.
  • We may eventually want to include other dynamic content, like placeholders, in index.html.

There's a Webpack plugin called HtmlWebpackPlugin that will generate HTML files for us. Let's install it:

npm install --save-dev html-webpack-plugin

First we need an HTML template for HtmlWebpackPlugin to use. Create index.html and place it in the src directory:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Boilerplate!</title>
  </head>
  <body></body>
</html>

This file will be used as a basis for the generated index.html, so feel free to add your own markup, like meta tags or Open Graph tags.

The <script> tag is omitted because it will be added by HtmlWebpackPlugin automatically.

To use the plugin we need to import it at the top of webpack.config.js. We also need to enable it by adding it to the plugins array of the Webpack configuration object. Since this is the first plugin we add, we need to create the plugins array (view commit):

// webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin'); // NEW LINE

// ...

    module: {
        // ...
    },

    // NEW CODE:
    plugins: [
      // This plugin will generate an HTML5 file that imports all our Webpack
      // bundles using <script> tags. The file will be placed in `output.path`.
      // https://github.com/jantimon/html-webpack-plugin
      new HtmlWebpackPlugin({
        template: path.join(PATH_SOURCE, './index.html'),
      }),
    ],
    // END NEW CODE

  };
};

(Notice the import of HtmlWebpackPlugin at the top.)

You can test the changes by creating a new build:

npm run build

The automatically generated index.html has been added to dist/index.html.

Cleaning out the dist directory

You may have noticed that the dist directory has started to fill up with old bundles. Let's remedy this by installing a Webpack plugin that automatically cleans out the directory before every new build.

Install clean-webpack-plugin:

npm install --save-dev clean-webpack-plugin

Then, import the plugin at the top of webpack.config.js and add it to the plugins array (view commit):

// webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin'); // NEW LINE

// ...

    plugins: [
      // ...

      // NEW CODE:

      // This plugin will delete all files inside `output.path` (the dist directory),
      // but the directory itself will be kept.
      // https://github.com/johnagan/clean-webpack-plugin
      new CleanWebpackPlugin(),

      // END NEW CODE

    ],
  };
};

Webpack DevServer

Let's do a quick recap. Our bundle is emitted to the dist directory. The directory also contains a generated index.html file which references our bundle. The dist directory is actually ready to be deployed.

But there's one thing that quickly becomes annoying. We need to run Webpack each time we change our code. Is there a way to run our npm script, npm run dev, automatically whenever our code changes?

One way is by using Webpack's "watch mode". Adding --watch to the Webpack command will instruct Webpack to "watch" the source files and recompile whenever there is a change. But there's a more powerful option: Webpack's DevServer

The DevServer is a simple web server that serves content from the dist directory. Besides recompiling your bundle automatically, it will "live reload" your browser whenever your code recompiles.

Install DevServer:

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

Add the devServer option to our Webpack configuration object (view commit):

// webpack.config.js

    // ...
    'mode': environment,

    // NEW CODE:

    // Configuration options for Webpack DevServer, an Express web server that
    // aids with development and provides live reloading out of the box.
    devServer: {
      static: {
        // The dev server will serve content from this directory.
        directory: PATH_DIST,
      },

      // Specify a host and port number.
      host: "localhost",
      port: 8080,

      // When using the HTML5 History API (you'll probably do this with React
      // later), index.html should be served in place of 404 responses.
      historyApiFallback: true,

      client: {
        // Show a full-screen overlay in the browser when there are compiler
        // errors or warnings.
        overlay: {
          errors: true,
          warnings: true,
        },
      },
    },

    // END NEW CODE

   // ...

DevServer doesn't write any files after compiling. It won't write anything to dist. It keeps bundle files in memory and serves them as if they were real files mounted at the server's root path. For example, <script src="js/main.js"> will trigger a request to js/main.js, which will serve the contents of js/main.js from memory.

Add an npm script for the DevServer (view commit):

// package.json
{
  "scripts": {
    "build": "webpack --env.environment=production --config webpack.config.js",
    "dev": "webpack --env.environment=development --config webpack.config.js",
    "devserver": "webpack-dev-server --env.environment=development --config webpack.config.js"
  },
  // ...

And run it:

npm run devserver

This will fire up a web server on http://localhost:8080. You can visit the URL and check out your browser's development console. You should see the message we wrote with console.log.

Let's recap our three npm scripts:

  • npm run dev will create and emit a development bundle to the dist folder.
  • npm run prod will create and emit a production bundle to the dist folder.
  • npm run devserver will fire up Webpack's DevServer which creates a development bundle, stores it in memory, and serves it. It will also recompile and "live reload" whenever the code changes.

React

We need two packages to use React. First we need the generic React package (react). We also need react-dom which takes care of DOM-specific operations like rendering our application on the web platform.

npm install --save react react-dom

React components are typically written using JSX, a syntax extension to JavaScript. We won't learn JSX in this article, but you should know that this code:

const element = <h1>Hello, world!</h1>;

is a developer-friendly way of writing:

const element = jsx("h1", null, "Hello, world!");

(Why not React.createElement()? Because we're using the new JSX transform.)

Browsers don't understand JSX so we need to transform it to calls to jsx(), and import the jsx function.

There's a Babel preset that will do this for us: @babel/preset-react. This preset includes several plugins that are required to write a React app. It helps Babel understand the JSX syntax and converts our JSX to jsx() calls.

npm install --save-dev @babel/preset-react

Open webpack.config.js and add @babel/preset-react to the presets array under the rule for babel-loader (view commit):

// ...
options: {
  presets: [
    [
      "@babel/preset-env",
      {
        // ...
      },
    ],

    // NEW CODE:

    // The react preset includes plugins that are required for React
    // apps. For example, it inclues a plugin that transforms JSX.
    [
      "@babel/preset-react",
      {
        // Tell "plugin-transform-react-jsx" which runtime to use.
        // The "automatic" runtime will:
        // * Import the jsx() function in your JSX files:
        //   `import { jsx as _jsx } from "react";`
        // * Transform JSX: `<div />` to `_jsx("div")`
        runtime: "automatic",
      },
    ],

    // END NEW CODE
  ];
}
// ...

Now that Babel is ready to work with JSX, we can create our first React component and render it to the DOM. Replace the contents of src/index.js with this (view commit):

import React from "react";
import ReactDOM from "react-dom";

function Root() {
  return <h1>Hello, world.</h1>;
}

// Render the Root element into the DOM
ReactDOM.render(<Root />, document.getElementById("root"));

The call to ReactDOM.render() will render the React component <Root> into the supplied container. Our container is an HTML element with an ID of root. This element needs to exist in our HTML template, so let's add it to src/index.html:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Boilerplate!</title>
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

That's it. Fire up Webpack's DevServer, hit http://localhost:8080 and enjoy your exciting new app.

Where to go from here

The ecosystem is vast and your boilerplate will likely grow in complexity as you customize it further and add more plugins and presets. Maintaining your own boilerplate gives you a lot of flexibility, but it can also become frustrating to keep up with all the tools. If you don't think it's worth the effort, there's always create-react-app.

If you want to flesh out your boilerplate code, here are some tips on what to explore next:

  • Install the React DevTools.
  • Set up Hot Module Replacement
  • Tooling for CSS. Either a CSS-in-JS solution like Emotion, or a combination of style-loader, css-loader and a pre-processor loader like sass-loader if you want to keep your CSS away from your JS.
  • Check out copy-webpack-plugin. Put public files like favicons in a dedicated directory (e.g. src/public) and use this plugin to copy the directory's contents to dist.
  • Are you using PropTypes? Install babel-plugin-transform-react-remove-prop-types if you want to remove them in production.

A future article will likely cover some of these topics.

Read part two: Create a React app from scratch - ESLint and Prettier.