In 2025 - there are several well established React frameworks available:
Ever wonder what goes under the hood in such a framework? How do you bootstrap a minimal working React application, that can potentially be used in production? Or maybe you just don’t need the full feature set that the above frameworks offer, and want to create a minimal application with a minimal set of NPM dependencies?
You are in luck! I am going to show you one of the ways to build a minimal React application using the WebPack bundler.
A note on bundlers. Over the last 15 years (or so) many bundlers appeared on the scene. I.e. tools that take your source code, and produce a build folder that can be deployed to the web. They do things like minify the source code, inline code, resolve dependencies, connect other tools (linters, compilers, etc.), etc., etc. Some of the bundlers that I used in past projects are RequireJS, rollup.js, Browserify, Webpack, SWC, Rspack. Each bundler has their strengths and weaknesses. Each bundler can be measured against the other bundlers in terms of speed. Each bundler has a following, an ecosystem, and a dedicated development team.
I am choosing Webpack here because of three reasons:
So - with the introduction out of the way - let’s jump right in! We are going to create a new project, and the following folder & file structure:
mkdir -p ~/dev/sample-project && cd ~/dev/sample-project
mkdir ./public
touch ./public/index.html
mkdir ./src
touch ./src/index.tsx
touch ./src/App.tsx
touch ./build.sh
touch ./package.json
touch ./tsconfig.json
touch ./webpack.config.js
Pretty bare-bones - two directories, and 7 files!
First up - the config for WebPack (the file
webpack.config.js
).
const webpack = require("webpack");
const path = require("path");
.exports = {
moduleentry: path.join(__dirname, "src", "index.tsx"),
output: {
path: path.join(__dirname, "build"),
filename: "[name].js",
,
}module: {
rules: [
{test: /\.ts(x?)$/,
exclude: /node_modules/,
loader: "ts-loader",
options: {
context: __dirname,
,
},
},
],
}resolve: {
extensions: [".js", ".ts", ".tsx"],
,
}plugins: [
new webpack.SourceMapDevToolPlugin({ filename: "[name].js.map" }),
,
]devtool: false,
mode: "development",
infrastructureLogging: {
level: "error",
,
}stats: "errors-only",
; }
Pretty minimal - ehh?
Next - configuration for TypeScript (the file
tsconfig.json
):
{
"compilerOptions": {
"strict": true,
"jsx": "react",
"esModuleInterop": true,
"lib": [
"dom"
],
"sourceMap": true
}
}
We are going to use strict
mode from the start so that
later on in project development life cycle this will not be a pain
point.
Next - let’s specify the dependencies we will need (the file
package.json
):
{
"name": "react-from-scratch",
"version": "0.0.1",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "webpack build"
},
"author": "Valera Rozuvan",
"license": "MIT",
"description": "just React, just WebPack - minimal NPM dependencies",
"repository": {
"type": "git",
"url": "https://github.com/valera-rozuvan/react-from-scratch.git"
},
"engines": {
"node": ">=16.0.0"
},
"overrides": {
"@types/react": "19.0.10"
},
"dependencies": {
"react": "19.0.0",
"react-dom": "19.0.0"
},
"devDependencies": {
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",
"@types/webpack": "5.28.5",
"ts-loader": "9.5.2",
"typescript": "5.7.3",
"webpack": "5.98.0",
"webpack-cli": "6.0.1"
}
}
Please install the NPM deps we have specified by running:
npm install
This will pull a total of 139 child dependencies. Neat!
The static page will host the generated bundle (the JS file
containing our application and the React dependency). Let’s create it
(the file public/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">
<meta name="description" content="minimal React application" />
<meta name="author" content="Valera Rozuvan" />
<title>Minimal React application</title>
<script defer src="main.js"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
Note that we are including the main.js
script. Since we
are sticking to a minimal WebPack config (and a minimal number of NPM
deps), we are crafting the index.html
by hand.
When WebPack starts bundling our application - it first looks at the
entry point. Let’s define it (the file src/index.tsx
):
import React from "react";
import { createRoot } from 'react-dom/client';
import App from "./App";
const container = document.getElementById('root');
if (!container) {
throw new Error("Did not find an element with id 'root'.");
}
const root = createRoot(container);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>,
; )
NOTE: Our index.html
file should have
an element with the ID root
.
Our application’s top level component will be the App
React component (the file src/App.tsx
):
import React from 'react';
function App(): React.ReactElement {
return (
<>Hello, world!</>
;
)
}
export default App;
As goes the tradition for these kinds of things - initially our
application will show Hello, world!
.
And the final cherry - which allows us to generate a production-ready
build - is the build Bash script. Remember, we want a minimal WebPack
config, meaning we are not using any 3rd party plugins. This results in
fewer NPM dependencies. But this also means that we have to do several
things by hand. So there goes (the file build.sh
):
#!/bin/bash
## --[ STEP 0 ]---------------------------------------------
# This will cause the shell to exit immediately if a simple command exits with a nonzero exit value.
set -Eeuo pipefail
## --[ STEP 1 ]---------------------------------------------
echo "Building..."
rm -rf ./build
mkdir -p ./build
npm run build
## --[ STEP 2 ]---------------------------------------------
echo "index HTML"
cp ./public/index.html ./build/
## --[ STEP 3 ]---------------------------------------------
# Comment out the below 6 lines if you want to have source maps for debugging.
echo "Uglify..."
uglifyjs --compress --mangle --output ./build/main.min.js -- ./build/main.js
sed -i -- 's/main/main.min/g' ./build/index.html
rm -rf "./build/index.html--"
rm -rf "./build/main.js"
rm -rf "./build/main.js.map"
## --[ STEP 4 ]---------------------------------------------
# If we got here - all is good ;)
echo "Done!"
exit 0
If you paid careful attention to what’s inside the build script - you might have noticed that we are using uglifyjs for minification. So, before running this build script, please install it globally like so:
npm install -g uglify-js
Alright! If you managed to make it this far - let’s try and produce
our first build. A small reminder - make sure that build.sh
has executable permissions for your system user:
chmod u+x ./build.sh
./build.sh
You should see something like the following:
Building...
> react-from-scratch@0.0.1 build
> webpack build
index HTML
Uglify...
Done!
You can now inspect the results in the generated ./build
folder. Go on, try and open the file ./build/index.html
with a web browser. You should get the Hello, world!
message on a blank page.
If you want to ease the development, and have the
build.sh
script executed as you edit the source code, I
have a handy watch.js
script which does just that:
const { exec } = require('child_process');
const fs = require('fs');
const chokidar = require('chokidar');
function createErrorPage(errorMsg) {
const fileName = './build/index.html';
.writeFile(fileName, errorMsg.replaceAll('\n', '<br />'), (err) => {
fsif (err) {
console.error('Error creating or writing to file:', err);
return;
}console.log(`Error page has been created.`);
;
})
}
function runBashScript(scriptPath, args = []) {
return new Promise((resolve, reject) => {
const command = `${scriptPath} ${args.join(' ')}`;
exec(command, (error, stdout, stderr) => {
if (error) {
console.log(stdout);
reject({ shortMsg: `Error executing script: ${error.message}`, stdout, stderr });
return;
}if (stderr) {
console.warn(`Script stderr: ${stderr}`);
}resolve(stdout);
;
});
})
}
let buildRunning = false;
let scheduleRun = false;
function runBuild() {
if (buildRunning) {
= true;
scheduleRun return;
}= true;
buildRunning
runBashScript('./build.sh', ['arg1', 'arg2'])
.then((output) => {
console.log('Bash script output:');
console.log(output);
= false;
buildRunning
if (scheduleRun) {
= false;
scheduleRun runBuild();
}
}).catch((error) => {
console.error('Error:', error.shortMsg);
= false;
buildRunning
createErrorPage(error.stdout + '\n' + error.stderr);
if (scheduleRun) {
= false;
scheduleRun runBuild();
};
})
}
function addEventHandlers(watcher) {
watcher.on('add', function(path) {console.log('File', path, 'has been added'); runBuild();})
.on('change', function(path) {console.log('File', path, 'has been changed'); runBuild();})
.on('unlink', function(path) {console.log('File', path, 'has been removed'); runBuild();})
.on('error', function(error) {console.error('Error happened', error);})
}
function createWatcher(path) {
return chokidar.watch(path, {
ignored: /^\./,
persistent: true,
awaitWriteFinish: true,
;
})
}
'src/', 'public/', 'webpack.config.js', 'tsconfig.json', 'package.json', 'package-lock.json', 'build.sh'].forEach((path) => {
[const watcher = createWatcher(path);
addEventHandlers(watcher);
; })
To use it - you need to add one more dependency to the
package.json
file:
"chokidar": "4.0.3",
Install it:
npm install
Then you can run in a separate terminal:
node ./watch.js
Test this by editing the file src/App.tsx
, and saving
it. You should see that the build
folder was
re-generated.
We are at a point where we have a working application. We have used a minimal number of NPM dependencies. We also understand what is happening under the hood. The next steps are up to you! This starter project can become whatever you wish.
I have taken this starter, and created a small application which generates GPG keys in the browser. You can see it live at gpg-keys-online.rozuvan.net, and find the sources at github.com/valera-rozuvan/react-from-scratch.
I also recorded several YouTube videos explaining the motivation behind this post, and how things work. You can watch these at:
So long and thanks for all the fish 😉