Bootstrapping React app with Webpack + Babel from Scratch

22 Aug 2022
12 min read

Webpack is a popular module bundling system built on top of Node. js. It can handle the combination and minification of JavaScript and CSS files and other assets such as image files, fonts, etc. through the use of plugins.

Before we jump into webpack here’s the full list of features we are going to set up:

  1. Webpack v4
  2. Hot Module Replacement (HMR)
  3. Code Splitting
  4. PWA
  5. SSR (Server Side Rendering)
  6. React Setup
  7. React Router DOM
  8. Configuring Prettier and Eslint (for code formatting)
  9. Storybook
  10. CI/CD Pipelines
  11. Pre-commit hooks (Husky)
  12. Bundle Analyzing

Pre-requisites

  • npm / yarn
  • node
  • Basic knowledge of React and React Router

So without wasting much time, let’s start with the configuration.

Initializing npm project

npm init

Now we will install the webpack and babel packages/plugins to configure the webpack

npm i webpack-cli @babel/core @babel/preset-env @babel/preset-react
npm i better-npm-run webpack-dev-server
npm i babel-loader file-loader postcss-loader sass-loader css-loader
npm i html-webpack-plugin extract-css-chunks-webpack-plugin clean-webpack-plugin webpack
npm i copy-webpack-plugin webpack-manifest-plugin webpack-merge webpack-bundle-analyzer
npm i workbox-webpack-plugin optimize-css-assets-webpack-plugin compression-webpack-plugin

  • webpack-CLI: webpack CLI provides a flexible set of commands to increase speed when setting up a custom webpack project.
  • @babel/core: It provides basic core babel configuration
  • @babel/preset-env: It allows to work with the latest ES6/ES7/ES8 features
  • @babel/preset-react: It allows to work with react syntax which is JSX
  • better-npm-run : Avoid hard-coded commands in package.json.
  • webpack-dev-server : development server that provides live reloading.
  • babel-loader: This package allows transpiling JavaScript files using Babel and webpack.
  • file-loader: The file-loader resolves import/require() on a file into a URL and emits the file into the output directory.
  • postcss-loader: This loader processes CSS with PostCSS.
  • sass-loader: Loads a Sass/SCSS file and compiles it to CSS.
  • css-loader: The css-loader interprets @import and url() like import/require() and will resolve them.
  • Html-webpack-plugin: Can generate an HTML file for your application, or you can provide a template
  • extract-css-chunks-webpack-plugin: This plugin extracts CSS into separate files.
  • clean-webpack-plugin: A webpack plugin to remove/clean your build folder(s).
  • copy-webpack-plugin: Copies individual files or entire directories, which already exist, to the build directory.
  • webpack-manifest-plugin: A Webpack plugin for generating an asset manifest.
  • webpack-merge: webpack-merge provides a merge function that concatenates arrays and merges objects creating a new object. (For copying configs from common config. to dev and prod config.)
  • webpack-bundle-analyzer: Visualize the size of webpack output files with an interactive zoomable treemap.
  • workbox-webpack-plugin: It generates a complete service worker for you and one that generates a list of assets to precache that is injected into a service worker file.
  • optimize-css-assets-webpack-plugin: A Webpack plugin to optimize \ minimize CSS assets.
  • compression-webpack-plugin: Prepare compressed versions of assets to serve them with Content-Encoding.

Now let’s discuss the folder structure of our project.

Here we have flowing folders:

  • public: this will contain the static assets and main HTML file of our app.
  • src: this will have React code.
  • webpack: this will contain webpack configuration for both client and server.
  • webpack/client: this will contain webpack configuration for the client.
  • webpack/server: this will contain webpack configuration for the server(for SSR).

Also, we will have a separate webpack configuration file for each environment i.e (Development and Production) for both “client” and “server”.

  • Development : “webpack.dev.config.js”
  • Production: “webpack.prod.config.js”
  • Common: “webpack.common.config.js” (this will have the common configurations for both prod and dev environments).

The reason we have a “common” config is that since there will be some common/basic configuration that will be required in both the environment (development and Production) so to eliminate code redundancy we have webpack.common.config.js, which we will import in dev and prod config file.

So before configuring webpack first let’s understand how webpack works.

There are 3 main things webpack needs to know

  1. The starting point of your application
  2. Which type for transformations to make on a particular asset i.e how you want your .html,.js and .css and other assets files to be transformed (Loders)
  3. Where it should save the new transformed code.

Also other required plugins

Let’s setup babel.config.js / .babelrc in root directory.

babel.config.js

module.exports = {
presets: [
[
'@babel/preset-env',
{
targets: {
esmodules: true,
},
},
],
'@babel/preset-react',
],
};

This tells Babel to use the presets (plugins) we previously installed. Later, when we call babel-loader from Webpack, this is where it will look to know what to do.

  • Specify an entry point

In “webpack/client/webpack.common.config”

module.exports = {
entry: {
app: [path.resolve(__dirname, '../../src/index.jsx')],
},
.....
}

  • Add loaders to handle your assets

module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
},
},
{
test: /\.(sa|sc|c)ss$/,
use: [
{
loader: ExtractCssChunks.loader,
options: {
hmr: true,
esModule: true,
},
},
{
loader: 'css-loader',
options: {
sourceMap: env === 'development',
modules: {
mode: 'local',
exportGlobals: true,
localIdentName: env === 'development' ? '[name]__[local]__[hash:base64:5]' : '[hash:base64:5]',
context: path.resolve(__dirname, '../../src'),
hashPrefix: 'React Enterprice kit',
},
},
},
{
loader: 'postcss-loader',
options: {
sourceMap: env === 'development',
},
},
{
loader: 'sass-loader',
options: {
sourceMap: env === 'development',
},
},
],
},
{
test: /\.(png|jpe?g|gif|webp|svg)$/i,
loader: 'file-loader',
options: {
name: '[name].[ext]',
outputPath: 'assets/images',
},
},
],
},

In the configuration, we have rules that tell webpack how to handle a particular asset. The configuration has the following fields:

  1. test: It takes a regular expression that matches the project asset extinction.
  2. loader: Name of loader which will interpret the asset.
  3. use: It takes an array if we want to configure assets with multiple loaders.
  4. options: Optional field which takes output path, name, etc.
  • Specify the output

output: {
filename: env === 'development' ? '[name].js' : '[name].[hash].js',
path: path.resolve(__dirname, '../../dist'),
chunkFilename: 'scripts/[name].[hash].js',
},

As the options name suggests the

  • filename: The name of the file output file. _ path: Where the output file should be emitted.
  • chunkFilename: The js files name which will be emitted after build.

Now we have configured our webpack to at least make a build and give the minifies and optimized code.

Final webpack.common.config.js

module.exports = {
entry: {
app: [path.resolve(__dirname, '../../src/index.jsx')],
},
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
},
},
{
test: /\.(sa|sc|c)ss$/,
use: [
{
loader: ExtractCssChunks.loader,
options: {
hmr: true,
esModule: true,
},
},
{
loader: 'css-loader',
options: {
sourceMap: env === 'development',
modules: {
mode: 'local',
exportGlobals: true,
localIdentName: env === 'development' ? '[name]__[local]__[hash:base64:5]' : '[hash:base64:5]',
context: path.resolve(__dirname, '../../src'),
hashPrefix: 'React Enterprice kit',
},
},
},
{
loader: 'postcss-loader',
options: {
sourceMap: env === 'development',
},
},
{
loader: 'sass-loader',
options: {
sourceMap: env === 'development',
},
},
],
},
{
test: /\.(png|jpe?g|gif|webp|svg)$/i,
loader: 'file-loader',
options: {
name: '[name].[ext]',
outputPath: 'assets/images',
},
},
],
},
resolve: {
extensions: ['.js', '.jsx'],
},
mode: process.env.NODE_ENV,
plugins,
optimization: {
splitChunks: {
name: 'vendor',
chunks: 'all',
minChunks: 1,
cacheGroups: {
defaultVendors: {
test: /[\\/]node_modules[\\/]/,
},
},
},
moduleIds: 'hashed',
runtimeChunk: 'single',
},
output: {
filename: env === 'development' ? '[name].js' : '[name].[hash].js',
path: path.resolve(__dirname, '../../dist'),
chunkFilename: 'scripts/[name].[hash].js',
},
};

Now in “webpack.dev.config.js” will import the common config and add some more config that is required in development only (i.e live sever to run project etc.)

To run our react app locally we will use the “webpack-dev-server” plugin.

webpack.dev.config.js

const { merge } = require('webpack-merge');
const path = require('path');
const Webpack = require('webpack');
const common = require('./webpack.common.config');

const plugins = [
new Webpack.DefinePlugin({
__CLIENT__: true,
__SERVER__: false,
__DEVELOPMENT__: true,
__DEVTOOLS__: true,
}),
];
if (process.env.analyze) {
plugins.push(
new BundleAnalyzerPlugin({
openAnalyzer: true,
reportFilename: 'bundleReport.html',
analyzerMode: 'static',
token: 'c3e980d3ec23ddd626fecc110501e76a9a469461',
}),
);
}

module.exports = merge(common, {
devtool: 'inline-source-map',
devServer: {
contentBase: path.join(__dirname, '../../dist'),
port: 3000,
hot: true,
},
plugins
});

Here we import the common configurations from “./webpack.common.config” and with “webpack-merge” plugin will merge the dev congif to it. In dev config, we are setting dev to serve by passing the following options:

  • contentBase: The path/folder on which the server will run, which will eventually have all static files
  • port: Port at which server will open
  • hot: To enable hot reload, this will trigger serve to reload whenever there is any code change.

For analyzing the bundle, we use the “webpack-bundle-analyzer” plugin, which will emit an HTML file to visualize the app’s bundles.

“Webpack.DefinePlugin” plugin set’s the env variable for the mode in which the webpack has been triggered.

Similarly for production, we will configure webpack

webpack.prod.config.js

const path = require('path');
const { merge } = require('webpack-merge');
const Webpack = require('webpack');
const CompressionPlugin = require('compression-webpack-plugin');
const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const { InjectManifest } = require('workbox-webpack-plugin');
const common = require('./webpack.common.config');

const plugins = [

new Webpack.DefinePlugin({
__CLIENT__: true,
__SERVER__: false,
__DEVELOPMENT__: false,
__DEVTOOLS__: false, // For disabling redux devtools on Production mode
}),
new CompressionPlugin({
algorithm: 'gzip',
filename: '[path].gz[query]',
test: /\.(js|jsx)$|\.css$|\.html$/,
}),
new OptimizeCssAssetsPlugin({
cssProcessorPluginOptions: {
preset: ['default', { discardComments: { removeAll: true } }],
},
}),
new InjectManifest({
swSrc: path.resolve(__dirname, '../../src/service-worker.js'),
maximumFileSizeToCacheInBytes: 5000000,
}),
];

module.exports = merge(common, {
plugins,
});

In production, we need to optimize our assets and also compress them to make the performance faster. So for compressing our assets we are using compression-webpack-plugin which will compress .js, .jsx, .css, .html, etc. to any compression algorithm so it can be served much faster to the server.

And for optimizing/minimizing CSS assets we have used the optimize-css-assets-webpack-plugin which will optimize CSS files in our project by removing whitespace, removing comments, etc.

Instead of writing the whole service-worker by ourselves, we will use “workbox-webpack-plugin/InjectManifest”. The InjectManifest plugin will generate a list of URLs to precache and add that precache manifest to an existing service worker file. It will otherwise leave the file as-is. To learn more visit here

Now we have configured Webpack to run in both development and production mode, so now will setup commands to trigger webpack in package.json

Here will use the better-npm-run package to avoid hearing coded commands in scripts. This will help us in the future as we’ll have many commands to run in scripts.

package.js

"scripts": {
"start-dev": "better-npm-run start-dev",
"build-dev": "better-npm-run build-dev",
"build-prod": "better-npm-run build-prod",
"build-analyze": "better-npm-run build-dev-analyze",
},

"betterScripts": {
"build-dev": {
"command": "webpack --progress --config ./webpack/client/webpack.dev.config.js",
"env": {
"NODE_ENV": "development",
"PORT": 3000
}
},
"build-prod": {
"command": "webpack --progress --config ./webpack/client/webpack.prod.config.js",
"env": {
"NODE_ENV": "production",
"PORT": 3000
}
},
"build-dev-analyze": {
"command": "webpack -w --progress --config ./webpack/client/webpack.dev.config.js",
"env": {
"NODE_ENV": "development",
"PORT": 3000,
"analyze": true
}
},
"start-dev": {
"command": "webpack-dev-server --config ./webpack/client/webpack.dev.config.js --open",
"env": {
"NODE_ENV": "development",
"PORT": 3000
}
}
},

We have added 4 new commands:

  1. “npm run start-dev”: This will start the dev server locally at the given port number in development mode.
  2. “npm run build-dev”: This will trigger a build for “development” mode, And will emit a “dist” in the root.
  3. “npm run build-prod”: This will trigger a build for ” production” mode, And will emit a “dist” in the root.
  4. “npm run build-analyze”: This will trigger a build for development mode with one “bundleReport.html” file which will have all the bundle details.

Now, we will set up out our react application.

In the “./public” folder we need our base “index.html” file and also icons and other necessary files.

“./public/index.html”

<!DOCTYPE html>
<html lang="en">
<head>
<title>React Enterprise Starter Kit</title>
<link rel="icon" type="image/ico" href="favicon.ico" />
<link rel="manifest" href="manifest.json">
<meta name="theme-color" content="#01579b"/>
<link rel="apple-touch-icon" href="/react.png">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="Description" content="React Enterprise Starter Kit, Author: Anand Gupta (github: anandgupta193)">
</head>
<body>
<noscript>
If you're seeing this message, that means
<strong> Javascript has been disabled on your browser </strong>
Please enable JS to make this app work.
</noscript>
<div id="root"></div>
</body>
</html>

React app will render inside div with id “root”. And here we can also configure our meta tags.

Now for making react app installable for PWA we will also have a “manifest.json” file

“./public/manifest.json”

{
"icons": [
{
"src": "react.png",
"sizes": "384x384",
"type": "image/png"
},
{
"src": "react.png",
"sizes": "256x256",
"type": "image/png"
},
{
"src": "react.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "react.png",
"sizes": "128x128",
"type": "image/png"
},
{
"src": "react.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "react.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"name": "React App",
"short_name": "Application",
"orientation": "portrait",
"display": "standalone",
"start_url": "/",
"description": "Description!",
"background_color": "#01579b",
"theme_color": "#01579b",
"prefer_related_applications": true
}

Now, will set up a basic React application in the “src” folder.

“./src/index.js”

import React from 'react';
import ReactDOM from 'react-dom';
import App from './app';

ReactDOM.render(
<App />,
document.getElementById('root'),
);

“app.js”

import React from 'react';

function App() {
return (
<div className="App">
<header className="App-header">
<p>
React App
</p>
</header>
</div>
);
}

export default App;

Now we are good to start our React app, Run the command “npm run start-dev”

I will cover SSR and more other stuff in the next blog. 🙂

For reference here is the project to which I have been contributing since the beginning React Starter Kit