Building a React Boilerplate from Scratch (Without Create React App)

When I join a new team, the first thing I ask is: “Do we understand our build?” If the answer is fuzzy, I build a minimal React boilerplate from scratch at least once. It’s like assembling a bike from parts rather than buying a fully built one—you learn what every bolt does, and you can fix it when something squeaks later. This matters even more in 2026, where toolchains evolve fast, bundlers are pluggable, and AI assistants can generate code, but they can’t explain why your CSS loader broke after a node upgrade.

In this guide, I’ll walk you through a clean, practical React setup without create-react-app. You’ll see how the folder structure hangs together, how Babel handles JSX, how Webpack bundles assets, and how a dev server gives you fast feedback. I’ll also show the modern React 18 entry point, a sane package.json, and guardrails like linting and source maps. By the end, you’ll have a working boilerplate you can grow, plus a mental model that makes debugging feel boring—in a good way.

The Minimal Project Layout That Scales

I keep the initial structure small but purposeful. Think of it as the wiring closet for your app: power in, power out, clearly labeled.

Here’s a starter layout that stays readable as the app grows:

my-react-app/

public/

index.html

src/

App.jsx

App.css

index.jsx

.babelrc

.gitignore

package.json

webpack.config.js

Why this layout works:

  • public/ holds static files that should not be processed by Webpack. The HTML file lives here and loads your bundle.
  • src/ holds all application code. I keep the entry file and root component at the top so new team members land quickly.
  • Tool config files live in the root for easy access and clear separation.

Simple analogy: public/ is the storefront sign and src/ is the actual shop inventory. You can repaint the sign without touching your inventory system, and you can reorganize the inventory without reprinting the sign.

Bootstrapping the Project Without Magic

I start with a clean directory and let npm generate the minimal metadata.

npm init -y

Then I add Git and keep dependencies out of version control:

git init

echo "node_modules" >> .gitignore

Why this matters: package.json becomes the single source of truth for dependencies, scripts, and versions. If your build breaks on a CI server, the first file you check is package.json, not a silent global install.

At this point, the directory is empty except for config. That’s good. Empty projects are honest, and honesty is great for debugging.

The HTML Shell and the React Entry Point

React still needs a real HTML file. You’ll put a root element there and instruct Webpack to inject your JavaScript bundle.

public/index.html:







React Boilerplate


Please enable JavaScript to view this site.

Next, create the React entry file. In 2026, I use React 18’s createRoot API because it’s the current standard, and it also sets you up for concurrent rendering features later.

src/index.jsx:

import React from "react";

import { createRoot } from "react-dom/client";

import App from "./App";

const root = createRoot(document.getElementById("root"));

root.render();

And a simple component so the page actually shows something:

src/App.jsx:

import React from "react";

import "./App.css";

const App = () => {

return (

React Boilerplate

You built this with your own toolchain.

);

};

export default App;

src/App.css:

.page {

display: grid;

place-items: center;

min-height: 100vh;

font-family: "Source Serif 4", serif;

background: #f7f4ee;

}

.heading {

color: #1b4d3e;

margin-bottom: 0.25rem;

}

.subheading {

color: #4b5a56;

}

I picked a serif font to keep the page visually distinct from the default system font. If you don’t want external fonts yet, remove that line. The core idea is that styles should flow through Webpack so you control them centrally.

Babel: Modern JavaScript Without the Headache

Most browsers still don’t support every modern JS feature. Babel translates newer syntax and JSX to a format browsers understand. It’s your interpreter between the language you write and the language the browser runs.

Install Babel and its presets:

npm install --save-dev @babel/core @babel/cli @babel/preset-env @babel/preset-react

Add a Babel config file in the root:

.babelrc:

{

"presets": ["@babel/preset-env", "@babel/preset-react"]

}

Notes from experience:

  • @babel/preset-env reads your target browsers list. If you want to be precise later, add a browserslist entry in package.json.
  • @babel/preset-react handles JSX and the modern JSX runtime. It works fine with React 18 out of the box.

This is a simple setup, but it’s clear. When a teammate asks “Where does JSX get converted?”, you can point to a single file, not a 300-line meta config hidden in a scaffolded template.

Webpack: The Bundler That Glues It All Together

Babel converts syntax, but it doesn’t bundle files. Webpack takes all your modules—JS, CSS, images—and creates a bundle the browser can load.

Install Webpack and the dev server:

npm install --save-dev webpack webpack-cli webpack-dev-server

Then install the loaders you need for JS and CSS:

npm install --save-dev babel-loader css-loader style-loader

I also add HTML Webpack Plugin so it can inject the script tag for you:

npm install --save-dev html-webpack-plugin

Now create webpack.config.js:

const path = require("path");

const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = {

mode: "development",

entry: path.resolve(dirname, "src", "index.jsx"),

output: {

path: path.resolve(dirname, "dist"),

filename: "bundle.js",

clean: true

},

devtool: "source-map",

module: {

rules: [

{

test: /\.jsx?$/,

exclude: /node_modules/,

use: "babel-loader"

},

{

test: /\.css$/,

use: ["style-loader", "css-loader"]

}

]

},

resolve: {

extensions: [".js", ".jsx"]

},

plugins: [

new HtmlWebpackPlugin({

template: path.resolve(dirname, "public", "index.html")

})

],

devServer: {

static: path.resolve(dirname, "public"),

port: 3000,

open: true,

hot: true,

historyApiFallback: true

}

};

A few things I care about here:

  • clean: true keeps old bundles out of your build folder.
  • devtool: "source-map" gives readable stack traces; without it, debugging is a chore.
  • historyApiFallback: true is essential for React Router if you add it later.

At this point, you have enough to run React without any scaffolding tool.

Wiring the Scripts and Running the App

Add scripts to package.json so your workflow is one command away.

package.json (relevant parts):

{

"scripts": {

"start": "webpack serve --mode development",

"build": "webpack --mode production"

}

}

Install React and React DOM:

npm install react react-dom

Now run it:

npm run start

You should see the page in your browser. If something goes wrong, here’s my quick check list:

  • Is public/index.html present and valid?
  • Did webpack.config.js point to the correct entry file?
  • Is the Babel config valid JSON?
  • Did you install the loaders? (Missing loader errors are common.)

The first time you get it running feels great because nothing is hidden. Every piece is visible and explainable.

Traditional vs Modern Workflow Choices

People often ask whether they should keep building with Webpack or switch to a faster dev tool. I still like understanding Webpack, even if I use faster tools day to day. Here’s a direct comparison I use with teams.

Traditional Manual Build

Modern Tool-Assisted Build

You configure Webpack, loaders, and Babel yourself

A tool like Vite or Rsbuild sets up defaults

Longer first setup, more control over internals

Faster setup, fewer decisions at the start

Debugging is easier because you know each layer

Debugging is faster when defaults are stable

Great for learning and unusual edge cases

Great for common apps with typical needs

You own every config change

You update the tool and follow its conventionsMy recommendation: build from scratch at least once, then decide. If you’re shipping production apps weekly, a tool-assisted build is usually better. But if you’re creating a custom platform or teaching your team, manual setup pays off.

Common Mistakes I See (and How You Avoid Them)

I’ve watched enough builds break to keep a checklist on my desk:

  • JSX without Babel

If you forget @babel/preset-react, Webpack will fail to parse JSX. The error is loud but the fix is simple: add the preset and restart the dev server.

  • Incorrect entry path

If entry points to src/index.js but your file is index.jsx, the build fails. I always include .jsx in the path to keep the intent clear.

  • Missing loader for CSS

import "./App.css" fails without style-loader and css-loader. Install and configure them together.

  • No source maps

Without devtool: "source-map", your stack traces point to bundle lines that mean nothing. The time cost of adding source maps is tiny compared to the time saved during debugging.

  • Production build that includes dev settings

If you forget to run webpack --mode production, your production bundle can be large and slow. Add a build script and make it part of your release process.

Performance and Real-World Considerations

A manual setup gives you a direct handle on performance. In my experience:

  • A small React bundle with this setup usually loads in 150–350ms on a fast connection, and 600–1200ms on a typical mobile connection.
  • Source maps add weight in dev, but they’re worth it. You can drop them in production by setting a different devtool or omitting it.
  • CSS injection via style-loader is fine for small apps. For larger apps, I move to CSS extraction plugins to reduce runtime work.

If you’re building a dashboard with 20+ routes and third-party charting libraries, I add code splitting early. That’s when I introduce import() and route-based chunks. This keeps initial load snappy while still shipping all features.

When I Use This Approach (and When I Don’t)

Here’s my honest guidance:

Use this manual boilerplate when:

  • You’re teaching a team or onboarding juniors who need to see the build pipeline.
  • You’re building a custom internal platform with special bundling needs.
  • You want full control over Babel and Webpack behavior.

Skip it when:

  • You need to ship a prototype in a day.
  • Your team already agrees on a tool like Vite and your app matches common patterns.
  • You want to focus on product features rather than build tooling.

If you’re unsure, I suggest building this once and then deciding. The time cost is small compared to the clarity you gain.

Modern Extras You Can Add Without Losing Clarity

Once the base is stable, I add only the pieces that help daily work. I keep them optional so the boilerplate stays light.

1) ESLint + Prettier

They keep code style consistent and help catch mistakes. I use them as guardrails, not as blockers. Add these packages and a minimal config, then wire npm run lint.

2) TypeScript (Optional)

If your app is large or long-lived, TypeScript pays off. I switch App.jsx to App.tsx and add ts-loader or babel-loader with @babel/preset-typescript.

3) React Fast Refresh

Webpack can support fast refresh in 2026 with the right plugin setup. It’s worth it if you’re doing a lot of UI iteration.

4) Asset Modules for Images

Webpack 5 can handle images without extra loaders. I add a rule for asset/resource when the app starts to include brand files or product screenshots.

I keep these as layers, like adding shelves to a closet. You don’t need every shelf on day one.

A Full Example You Can Run Today

Below is a clean, runnable setup. If you copy these files into a directory and run the commands, you will get a working React app.

package.json:

{

"name": "react-boilerplate",

"version": "1.0.0",

"private": true,

"scripts": {

"start": "webpack serve --mode development",

"build": "webpack --mode production"

},

"dependencies": {

"react": "^18.3.0",

"react-dom": "^18.3.0"

},

"devDependencies": {

"@babel/cli": "^7.26.0",

"@babel/core": "^7.26.0",

"@babel/preset-env": "^7.26.0",

"@babel/preset-react": "^7.25.0",

"babel-loader": "^9.2.0",

"css-loader": "^7.2.0",

"html-webpack-plugin": "^5.6.0",

"style-loader": "^4.0.0",

"webpack": "^5.96.0",

"webpack-cli": "^5.2.0",

"webpack-dev-server": "^4.15.0"

}

}

.babelrc:

{

"presets": ["@babel/preset-env", "@babel/preset-react"]

}

webpack.config.js:

const path = require("path");

const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = {

mode: "development",

entry: path.resolve(dirname, "src", "index.jsx"),

output: {

path: path.resolve(dirname, "dist"),

filename: "bundle.js",

clean: true

},

devtool: "source-map",

module: {

rules: [

{

test: /\.jsx?$/,

exclude: /node_modules/,

use: "babel-loader"

},

{

test: /\.css$/,

use: ["style-loader", "css-loader"]

}

]

},

resolve: {

extensions: [".js", ".jsx"]

},

plugins: [

new HtmlWebpackPlugin({

template: path.resolve(dirname, "public", "index.html")

})

],

devServer: {

static: path.resolve(dirname, "public"),

port: 3000,

open: true,

hot: true,

historyApiFallback: true

}

};

public/index.html:







React Boilerplate


Please enable JavaScript to view this site.

src/index.jsx:

import React from "react";

import { createRoot } from "react-dom/client";

import App from "./App";

const root = createRoot(document.getElementById("root"));

root.render();

src/App.jsx:

import React from "react";

import "./App.css";

const App = () => {

return (

React Boilerplate

You built this with your own toolchain.

What you own now

    • Entry points, not magic
    • Config you can debug
    • Build steps you can explain

);

};

export default App;

src/App.css:

.page {

display: grid;

place-items: center;

min-height: 100vh;

font-family: "Source Serif 4", serif;

background: linear-gradient(145deg, #f7f4ee, #f2efe8);

color: #1f2d2a;

padding: 3rem;

text-align: center;

}

.hero {

max-width: 720px;

}

.heading {

color: #1b4d3e;

font-size: clamp(2rem, 4vw, 3.5rem);

margin-bottom: 0.5rem;

}

.subheading {

color: #4b5a56;

font-size: 1.1rem;

margin-bottom: 2rem;

}

.callout {

margin-top: 1.5rem;

padding: 1.5rem 2rem;

border: 1px solid #d9d2c7;

border-radius: 14px;

background: #fffaf2;

box-shadow: 0 10px 30px rgba(27, 77, 62, 0.08);

}

.callout-title {

font-weight: 600;

margin-bottom: 0.75rem;

}

.callout-list {

list-style: none;

padding: 0;

margin: 0;

display: grid;

gap: 0.35rem;

}

With this in place, you can run npm run start and you’re live.

Deeper Understanding: How Each Part Talks to the Others

Here’s the mental model I teach to new team members:

  • The browser reads public/index.html first. It’s a static shell with a single div for React to own.
  • Webpack takes src/index.jsx as the entry point. That file imports App.jsx and App.css.
  • Babel transforms JSX into plain JavaScript. The browser doesn’t understand JSX; Babel does.
  • Loaders convert non-JS files into something JS can consume. CSS gets turned into runtime styles and injected into the page.
  • HtmlWebpackPlugin injects the script tag into the HTML. You don’t manually add .

This is a straight line from file to browser. There are no hidden steps. The clarity is the point.

Browserslist: Setting Your Target Without Guessing

@babel/preset-env looks for a browsers list to decide what syntax to transpile. If you don’t set it, Babel assumes reasonable defaults. I like to make it explicit once the project is real.

Add this to package.json:

{

"browserslist": [

">0.5%",

"last 2 versions",

"not dead"

]

}

This doesn’t change behavior much for modern apps, but it documents your intent. When someone asks why legacy syntax appears in the build, you can point to this section.

Splitting Dev and Production Configs (Without Losing Simplicity)

A single webpack.config.js is great at the start. When the app grows, you’ll want dev and production to diverge. I keep it simple by exporting a function based on the mode.

Here’s a minimal pattern that stays readable:

const path = require("path");

const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = (env, argv) => {

const isProd = argv.mode === "production";

return {

entry: path.resolve(dirname, "src", "index.jsx"),

output: {

path: path.resolve(dirname, "dist"),

filename: isProd ? "bundle.[contenthash].js" : "bundle.js",

clean: true

},

devtool: isProd ? "source-map" : "eval-cheap-module-source-map",

module: {

rules: [

{

test: /\.jsx?$/,

exclude: /node_modules/,

use: "babel-loader"

},

{

test: /\.css$/,

use: ["style-loader", "css-loader"]

}

]

},

resolve: {

extensions: [".js", ".jsx"]

},

plugins: [

new HtmlWebpackPlugin({

template: path.resolve(dirname, "public", "index.html")

})

],

devServer: {

static: path.resolve(dirname, "public"),

port: 3000,

open: true,

hot: true,

historyApiFallback: true

}

};

};

Notice the differences:

  • Dev source maps are faster (eval-cheap-module-source-map).
  • Production uses cache-friendly filenames (contenthash).
  • Output changes don’t affect your code layout or app logic.

This gets you 80% of the real-world benefits without the complexity of multiple config files.

Asset Handling Without Extra Loader Spam

Webpack 5 supports asset modules out of the box. That means you can import images without installing extra loaders.

Add a rule like this:

{

test: /\.(pngjpe?ggifsvg)$/i,

type: "asset/resource"

}

Now you can do:

import logo from "./assets/logo.png";

Webpack will emit the file and give you a URL. This makes asset management more predictable, especially in larger apps with multiple brand variations.

Styling Choices: Global CSS vs Modules vs CSS-in-JS

A boilerplate should reflect intent. For small apps, global CSS is fine. For larger apps, I migrate to CSS Modules or a CSS-in-JS library. Here’s how I decide:

  • Global CSS: fastest to start, least config. Good for small apps or marketing sites.
  • CSS Modules: scoped by default, helps avoid naming collisions. Requires a loader config change.
  • CSS-in-JS: theming and dynamic styles are easier, but it’s heavier and more opinionated.

If you want CSS Modules, you can extend the CSS rule:

{

test: /\.module\.css$/,

use: ["style-loader", {

loader: "css-loader",

options: { modules: true }

}]

}

Then you can write:

import styles from "./Button.module.css";

This keeps the global CSS rule for general styles and gives you a clear option for scoped styles.

H2: Fast Refresh for a Better Dev Loop

React Fast Refresh keeps component state between edits so the UI doesn’t reset on every save. That’s a huge quality-of-life improvement during UI work.

The basic idea:

  • Install the React Refresh plugin and Babel plugin.
  • Add Webpack’s ReactRefreshWebpackPlugin in development.
  • Update your Babel config to include the plugin only in dev.

This is optional, but I add it after the basics are stable because it makes development feel modern without losing transparency.

H2: Linting Without Getting in the Way

Linting is another “small cost, big win” addition. I keep it minimal at first:

  • ESLint with the React plugin
  • A simple config that enforces obvious mistakes
  • A lint script that’s easy to run

What I avoid early on:

  • Overly strict rules that slow the team
  • Too many config layers (keep it in .eslintrc.json)

If the team wants it, I later add Prettier to handle formatting so ESLint can focus on correctness.

H2: TypeScript Without the Pain

If you want TypeScript but don’t want to rebuild your toolchain, the easiest path is to add @babel/preset-typescript and update Webpack’s resolve extensions.

Minimal changes:

npm install --save-dev @babel/preset-typescript

Then in .babelrc:

{

"presets": ["@babel/preset-env", "@babel/preset-react", "@babel/preset-typescript"]

}

And update Webpack:

resolve: {

extensions: [".js", ".jsx", ".ts", ".tsx"]

}

You can then rename App.jsx to App.tsx and add basic typing. This is the least invasive way to get TypeScript going.

H2: Environment Variables (And Why They Break)

Environment variables are a common source of confusion in manual setups. In Webpack, you need to explicitly inject them into the build. The simplest approach is DefinePlugin.

Example:

const webpack = require("webpack");

plugins: [

new HtmlWebpackPlugin({ template: path.resolve(dirname, "public", "index.html") }),

new webpack.DefinePlugin({

"process.env.NODEENV": JSON.stringify(process.env.NODEENV || "development")

})

]

This lets you reference process.env.NODE_ENV in your code. If you need more variables, add them explicitly. This is a feature, not a bug—manual injection reduces the chance of leaking secrets into the client bundle.

H2: Routing and the Dev Server

When you add React Router, your app’s URL changes without a server reload. That’s why historyApiFallback: true matters. Without it, refreshing /settings gives you a 404, because the dev server tries to find a real file.

If you eventually deploy to a static host, you’ll need the same idea at the server level: always serve index.html for unknown routes.

H2: Production Builds and Cache Strategy

Production builds are where manual setup pays off. I recommend these changes:

  • Hashed filenames so the browser caches files safely.
  • Split vendor chunks so React isn’t re-downloaded on every small change.
  • Minification (Webpack does this automatically in production mode).

A simple split config looks like this:

optimization: {

splitChunks: {

chunks: "all"

}

}

The result is usually 2–4 bundles instead of one. That’s okay; modern browsers handle it well, and it keeps cache behavior predictable.

H2: Error Boundaries and Resilient UI

A boilerplate doesn’t need full app architecture, but I like to add a small example of an error boundary. It teaches teams how to handle UI failures without crashing the entire app.

Minimal example:

import React from "react";

class ErrorBoundary extends React.Component {

constructor(props) {

super(props);

this.state = { hasError: false };

}

static getDerivedStateFromError() {

return { hasError: true };

}

render() {

if (this.state.hasError) {

return

Something went wrong.
;

}

return this.props.children;

}

}

export default ErrorBoundary;

Then wrap your app in index.jsx. It’s a small example, but it establishes good habits.

H2: Edge Cases That Break Real Projects

Manual builds are powerful, but they also expose edge cases. Here are a few I’ve hit in real projects:

1) Node version mismatches

A loader that works on Node 20 might fail on Node 18. Lock your Node version with .nvmrc or engines in package.json.

2) CSS injection order

When styles get injected in a different order across builds, UI can flicker. If it becomes a problem, use a CSS extraction plugin.

3) Source maps in production

Publishing source maps can expose source code. Decide if you want them in production, and set devtool accordingly.

4) Large assets in the bundle

If you import big images directly, your bundle may balloon. Use asset modules and keep an eye on file sizes.

5) Dynamic imports without chunk naming

When you introduce import(), you’ll get auto-generated chunk names. It’s fine, but if you want clarity, add magic comments for readable names.

H2: Practical Scenarios and Decision Guides

I tell teams to use manual setup in these situations:

  • Building internal tooling with special bundling rules.
  • Teaching or onboarding where transparency matters.
  • Maintaining a legacy app that can’t switch to a new tool yet.

And I tell them to choose a modern tool when:

  • Time-to-market matters more than full control.
  • The team already knows a standard stack and prefers it.
  • Performance optimizations are mostly defaults (modern tools are excellent at this now).

Think of it like cooking: a manual build is from scratch, a modern tool is a meal kit. Both can taste great, but the learning experience is different.

H2: AI-Assisted Workflows Without Losing Control

In 2026, AI assistants can generate config files fast. That’s useful, but it can also hide complexity. My rule: use AI for speed, then verify the mental model.

A good workflow:

  • Use AI to draft the config.
  • Read every line and understand why it’s there.
  • Remove anything you can’t explain.

The goal is to keep your boilerplate small and explainable. AI can speed you up, but it shouldn’t replace your understanding.

H2: Deployment Basics Without Getting Lost

A boilerplate isn’t complete without a simple deployment story. The simplest path is:

  • Run npm run build
  • Deploy the dist/ folder
  • Configure your server to route all requests to index.html

This works for static hosts, CDN-based hosting, or a simple Node server. Once you understand the basics, you can add SSR or edge rendering later if your app needs it.

H2: Monitoring and Debugging in Production

Even with a clean build, real apps fail. Here’s what I add once a project is serious:

  • Error tracking (client-side) so you see crashes users experience.
  • Performance monitoring to spot slow routes and bloated bundles.
  • Build artifact sizes in CI so you can spot regressions.

These aren’t required on day one, but a boilerplate that anticipates them makes scaling easier.

H2: Alternative Bundler Choices (If You Want to Explore)

Webpack is still a solid choice, but you have options:

  • Vite: fast dev server, minimal config, great for most apps.
  • Rspack: Webpack-compatible but faster in many cases.
  • Rollup: great for libraries, less ideal for large apps with lots of assets.

Even if you stick with Webpack, knowing the alternatives helps you decide later without panic.

H2: A Practical Checklist for New Projects

Here’s the condensed checklist I give to teams:

  • [ ] index.html with a root element
  • [ ] index.jsx with createRoot
  • [ ] Babel with preset-env and preset-react
  • [ ] Webpack loaders for JS and CSS
  • [ ] Dev server with historyApiFallback
  • [ ] npm run start and npm run build
  • [ ] Optional: lint, format, fast refresh

If all of these pass, the boilerplate is ready.

H2: Why This Still Matters in 2026

Tooling moves fast. But the fundamentals—entries, loaders, builds—haven’t changed. When you know the plumbing, you don’t panic when a config breaks or a dependency updates. You can debug with confidence because you know where the wires go.

That’s the real win of building from scratch: not the folder structure or the config files, but the confidence it gives you when things go sideways.

Final Thoughts

A manual React boilerplate is not a punishment. It’s a learning tool and a control tool. You build it once to see the parts, and then you can decide whether to keep it or move to a faster tool. Either way, you now have a mental map of how React apps actually run—from HTML to JS to bundle to browser.

If you build this and you can explain every line to a teammate, you’ve done it right. That’s the whole point.

Scroll to Top