June 3, 2019 (updated March 17, 2020 )

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:

Create your own boilerplate code and try to understand each step of the configuration process. 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:

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

./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.)

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

const presets = [ [ "@babel/preset-env" ], ]; const plugins = []; 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):

const presets = [ [ "@babel/preset-env" , { debug : true , }], ]; const plugins = []; 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 2020. 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 :

import 'core-js/stable' ;

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.

: This will enable a plugin that transforms the import of core-js ( , like we did above), to imports of individual 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):

const presets = [ [ "@babel/preset-env" , { debug : true , useBuiltIns : 'usage' , corejs : 3 , }], ]; const plugins = []; 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:

var a = new Promise (); import "core-js/modules/es.promise" ; var a = new Promise ();

(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):

const path = require ( 'path' ); const PATH_SOURCE = path.join(__dirname, './src' ); const PATH_DIST = path.join(__dirname, './dist' ); module .exports = { mode : 'development' , entry : [ path.join(PATH_SOURCE, './index.js' ), ], 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 will be 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 for every build. This is great for HTTP caching. We can tell browsers to cache JavaScript files aggressively. Whenever we re-build the app, the bundle name (the hash) changes as well. This will break the browser's cache and force it to re-download the bundle.

An alternative is to use [contenthash] . This will use hashes generated for extracted content, which means the hash won't change unless your code changes.

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 [hash] 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" : {

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:

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 :

import sum from './sum' ; console .log(sum( 2 , 4 ))

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

output : { path : PATH_DIST, filename : 'js/[name].[hash].js' , }, module : { rules : [ { test : /\.js$/ , exclude : /node_modules/ , use : { loader : 'babel-loader' , options : { presets : [ [ '@babel/preset-env' , { debug : true , useBuiltIns : 'usage' , corejs : 3 , }], ], }, } } ], }, };

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:

module .exports = { } module .exports = env => { const environment = env.environment; const isProduction = environment === 'production' ; const isDevelopment = environment === 'development' ; return { mode : environment, } }

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

const path = require ( 'path' ); const PATH_SOURCE = path.join(__dirname, './src' ); const PATH_DIST = path.join(__dirname, './dist' ); module .exports = env => { const environment = env.environment; const isProduction = environment === 'production' ; const isDevelopment = environment === 'development' ; return { mode : environment, entry : [ path.join(PATH_SOURCE, './index.js' ), ], output : { path : PATH_DIST, filename : 'js/[name].[hash].js' , }, module : { rules : [ { test : /\.js$/ , exclude : /node_modules/ , use : { loader : 'babel-loader' , options : { presets : [ [ '@babel/preset-env' , { debug : true , useBuiltIns : 'usage' , corejs : 3 , }], ], }, } } ], }, }; };

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:

{ "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:

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

directory (using ), 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:

< html > < head > < meta charset = "utf-8" > < 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):

const path = require ( 'path' ); const HtmlWebpackPlugin = require ( 'html-webpack-plugin' ); module : { }, plugins : [ new HtmlWebpackPlugin({ template : path.join(PATH_SOURCE, './index.html' ), }), ], }; };

(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):

const path = require ( 'path' ); const HtmlWebpackPlugin = require ( 'html-webpack-plugin' ); const { CleanWebpackPlugin } = require ( 'clean-webpack-plugin' ); plugins: [ new CleanWebpackPlugin(), ], }; };

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

'mode' : environment, devServer : { contentBase : PATH_DIST, host : 'localhost' , port : 8080 , historyApiFallback : true , overlay : { errors : true , warnings : true , }, },

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

{ "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.

will create and emit a development bundle to the folder. npm run prod will create and emit a production bundle to the dist folder.

will create and emit a production bundle to the 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' , { }], '@babel/preset-react' , ] }

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 > ; } 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 :

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

, and a pre-processor loader like 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 .

) and use this plugin to copy the directory's contents to . 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.