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.