Modern Webpack Boilerplate: Part 2: Integrating Babel and React

Recap of Part 1: In the first installment of this series, we laid the groundwork for our modern React SPA boilerplate using Webpack 5. We covered the basics of Webpack, including core concepts like entry points, outputs, loaders, and plugins. We also set up our npm environment, ensuring consistency with Node v20 LTS, and introduced a basic TypeScript configuration. With a foundational Webpack setup in place, we're now ready to dive into integrating Babel, Webpack Dev Server and React to enhance our SPA's capabilities.

Babel setup

In order to use latest JavaScript features, while supporting older browsers, we need to use transpiler. In this configuration step, we will use babel.

Install following dev-dependencies:

npm i -D babel-loader @babel/preset-typescript @babel/preset-react @babel/preset-env @babel/core

Since preset-env requires core-js we need to install it as well

npm install core-js@3

Additionally we need to specify what browsers need to be supported. You can create the required file with following command.

echo -e "> 0.25%\nnot dead" > .browserslistrc

Last but not least to finalize babel setup we would need to create babel.config.js file with following presets:

  • @babel/preset-env - simply allow to use latest JS syntax, without worry about browser support
  • @babel/preset-typescript - project uses TypeScript, so we need it
  • @babel/preset-react - allows Babel to parse JSX and other React related stuff

Final config file looks like this

//babel.config.js

/**
 * @type {import('@babel/core').TransformOptions}
 */
const config = {
  presets: [
    [
      '@babel/preset-env',
      {
        useBuiltIns: 'usage',
        corejs: { version: '3.35', shippedProposals: true },
      },
    ],
    '@babel/preset-typescript',
    [
      '@babel/preset-react',
      {
        runtime: 'automatic',
        development: process.env.NODE_ENV !== 'production',
      },
    ],
  ],
}

module.exports = config

Now we need to tell Webpack how to handle tsx and ts files, current Webpack config need to be extended with following rules:

module: {
			rules: [
				{
					test: /\.(js|mjs|ts|tsx)$/,
					include: path.resolve(process.cwd(), "src"),
					loader: "babel-loader",
				},
			],
		},
		resolve: {
			extensions: [".ts", ".tsx", ".js"],
		},

This allow to pass all the files (tsx, ts, js) in src directory through babel and apply changes to use it safely in the browser.

Environment variables

To clear any confusion around envs on the frontend, let's clarify one thing: every env listed in .env.prod will be publicly available. If you need a way to hide some sort of API_KEY - you need most likely a backend proxy layer.

With public nature of envs on frontend world, lets dive into implementation. The idea is to provide our envs and build time, so when application is being deployed the envs will be available in the runtime. Without it we would end up with runtime error: process is no defined as it is part of Node.js not browser runtime.

The whole mechanism is easily extendable for adding new environments (staging, test or whatever you want) - but for now we will stick with basic setup for:

  • production
  • local

With that in mind lets create 3 files:

  • .env.prod - envs for production
  • .env.local - envs for local development Note: it must be added to .gitignore to not expose it
  • .env.example - example envs structure that matches .env.prod - new dev can easily copy it and fill with proper values

For now we can add only one variable called for instance ENVIRONMENT_NAME for simplicity it cane be either prod or local

Next install package dotenv which will help us manage the environment variables:

npm i -D dotenv

Once we have the envs and dotenv installed, we need to tell Webpack to add envs to code at build time. We need some helpers functions so it would be good idea to defined them under config/env.ts with following content:

import dotenv from 'dotenv'
import path from 'path'

type EnvironmentVariable = Record<string, string>

export const loadEnvs = (): EnvironmentVariable => {
  const environment = process.env.NODE_ENV || 'prod'
  const envPath = path.resolve(process.cwd(), `.env.${environment}`)
  const result = dotenv.config({ path: envPath })

  if (result.error) {
    console.error(`Failed to load .env file: ${envPath}`, result.error)
    throw result.error
  }

  return result.parsed || {}
}

export const mapEnvsToConfig = (envs: {
  [key: string]: string
}): EnvironmentVariable => {
  return Object.keys(envs).reduce<EnvironmentVariable>((prev, next) => {
    prev[`process.env.${next}`] = JSON.stringify(envs[next])
    return prev
  }, {})
}

Function loadEnvs is getting proper env file based on NODE_ENV environment variable and creates config using dotenv package.

However we still need to provide envs to Webpack in proper format, this task is handled by mapEnvsToConfig

Last step is to import those functions to webpack config and provide NODE_ENV for build npm script:

Webpack

//...
import { loadEnvs, mapEnvsToConfig } from './config/env'

const createWebpackConfig = (mode: Configuration['mode']): Configuration => {
  // load envs
  const envs = loadEnvs()
  const envsConfig = mapEnvsToConfig(envs)

  return {
    mode: mode,
    entry: path.resolve(process.cwd(), 'src/index.ts'),
    module: {
      rules: [
        {
          test: /\.(js|mjs|ts|tsx)$/,
          include: path.resolve(process.cwd(), 'src'),
          loader: 'babel-loader',
        },
      ],
    },
    resolve: {
      extensions: ['.ts', '.tsx', '.js'],
    },
    output: {
      path: path.resolve(process.cwd(), 'dist'),
      clean: true,
    },
    // pass them to Webpack so that process.env.ENVIRONMENT_NAME won't be undefined
    plugins: [new DefinePlugin(envsConfig)],
  }
}

Build script - we still need to pass NODE_ENV

"scripts": {
		"build": "webpack --config scripts/build.ts --node-env=prod"
	},

React

Install React

npm i react react-dom

And types for React

npm i -D @types/react-dom @types/react

Since we are working on SPA add index.html inside of new folder public

Example content of the index.html can be as follow

<!-- index.html -->

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Webpack React Boilerplate</title>
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
  </body>
</html>

Additionally we need to tell TypeScript that we are using JSX, add following line to compilerOptions of tsconfig.json

"jsx": "react-jsx",

Next we need dummy react component and bootstrap to mount application in root

Dummy app

// src/App.tsx

export const App = () => {
	return <p>Hello World</p>;
};
// src/bootstrap.tsx

import { createRoot } from 'react-dom/client'

import { App } from './App'

const render = () => {
  const container = document.getElementById('root')
  if (container) {
    const root = createRoot(container)
    root.render(<App />)
  }
}

render()

Using render function allows us to have flexibility to pass some configuration to it, for instance when you switch from build-time envs to runtime.

Last we need to update the src/index.ts to address changes done in bootstrap

// src/index.ts

import('./bootstrap');

export {};

Finally we will setup the dev-server to be able spin up the development environment.

Install dev-server package and html plugin

npm i -D webpack-dev-server html-webpack-plugin

In order to include webpack bundles automatically as script tags in index.html we need to add HTML plugin with some configuration

//webpack.config.ts

plugins: [
			new DefinePlugin(envsConfig),
			// new plugin for dealing with HTML
			new HtmlWebpackPlugin({
				inject: true,
				template: path.resolve(process.cwd(), "public/index.html"),
			}),
		],

Next we need function that encapsulate the creating dev-server setup create following file config/devServer.ts which will have that config function

import path from 'path'
import { WebpackConfiguration } from 'webpack-dev-server'

const defaultPort = Number(process.env.PORT) || 8080

export const createDevServerConfig = (): WebpackConfiguration['devServer'] => ({
  historyApiFallback: {
    disableDotRule: true,
    index: '/',
  },
  client: {
    overlay: false,
    logging: 'info',
  },
  static: {
    directory: path.resolve(process.cwd(), 'public'),
    serveIndex: true,
    watch: true,
  },
  liveReload: false,
  hot: true,
  open: false,
  port: defaultPort,
})

You can define PORT environment variable if you need more flexibility otherwise it will use default 8080

Generate the dev-server only for dev mode in webpack config

resolve: {
			extensions: [".ts", ".tsx", ".js"],
		},
		// use it only for dev mode
		...(mode === "development"
			? {
					devServer: createDevServerConfig(),
			  }
			: {}),
		output: {
			path: path.resolve(process.cwd(), "dist"),
			publicPath: "/",
			clean: true,
		},

Last but not least we need new scripts/start.ts to be able to use it in npm scripts like build command.

import createWebpackConfig from '../webpack.config'

export default () => {
  return createWebpackConfig('development')
}

Additionally it has to be specified in package.json as well

"scripts": {
		"build": "webpack --config scripts/build.ts --node-env=prod",
		"start": "webpack serve --config scripts/start.ts --node-env=local"
	},

That concludes this part. You can review all the details in following PR: https://github.com/Verthon/webpack-react-boilerplate/pull/2

Currently we have setup for modern JS, dev-server and React. In upcoming next part configuration will be extended with:

  • providing assets handling configuration
  • providing test configuration with Jest
  • basic configuration for CSS
  • adding bundle analyzer to track bundle-size in each build
  • minimal docs in the readme

Thank you, any feedback is highly appreciated.