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.

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 just 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: https://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 check out 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.

Syntax transformations

For Babel to do anything useful we need to enable some plugins. Plugins are responsible for transforming our code (and 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 73.) 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 already 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

last 2 versions
not dead
> 0.5%

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

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

./node_modules/.bin/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:

./node_modules/.bin/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:

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

@babel/preset-env: `DEBUG` option

Using targets:
{
  "android": "4.4.3",
  "chrome": "49",
  // ...
}

Using modules transform: auto

Using plugins:
  transform-template-literals { "android":"4.4.3", "ie":"11", "ios":"11.3", "safari":"12" }
  transform-literals { "android":"4.4.3", "ie":"11" }
  // ...

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 ECMAScript up to 2019. 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

That’s all.

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:

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#output-filename
  output: {
    path: PATH_DIST,
    filename: 'js/[name].[hash].js',
  },
};

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 is emitted to ./dist/js/[name].[hash].js. Webpack will substitute [name] with the entry name, which is main by default. [hash] will be replaced with a unique hash of the bundle contents. This is great for HTTP caching. We can tell browsers to cache JavaScript files aggressively. Whenever our code changes, the bundle name changes as well. This will will break the browser’s cache and force it to re-download the bundle.

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 NodeJS 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”.

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

// Exporting a function instead:
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.production` sets env.production = true
// `webpack --env.a = b` sets env.a = 'b'
// 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#output-filename
    output: {
      path: PATH_DIST,
      filename: 'js/[name].[hash].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.

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">
    <title>Boilerplate!</title>
  </head>
  <body>
  </body>
</html>

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’ve probably 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 during 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. It provides live reloading out of the box and can
    // be configured to do a lot more.
    devServer: {
      // The dev server will serve content from this directory.
      contentBase: PATH_DIST,

      // Specify a host. (Defaults to 'localhost'.)
      host: 'localhost',

      // Specify a port number on which to listen for requests.
      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,

      // 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 = React.createElement('h1', null, 'Hello, world!');

Browsers don’t understand JSX so we need to transform it to calls to React.createElement().

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 JSX to React.createElement() 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 several plugins that are required to write
                // a React app. For example, it transforms JSX:
                // <div> -> React.createElement('div')
                '@babel/preset-react',

                // 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: