From source to bundle
What one build command runs: which tool does each job in Create React App, Vite and Next.js, the resolve, transform, bundle, optimize and emit stages, dev vs production builds, and content hashes, each with a live demo.
npx create-react-app, npm create vite and create-next-app each hand you a project that just runs. Behind npm run build sits a chain of separate tools: one finds your files, one turns TypeScript and JSX into JavaScript, one handles CSS, one joins everything into bundles, one shrinks them. The sections below name each tool and step a small app through every stage; the last one shows this site's own build output, measured in your browser.
The next pages go deeper into each stage: transpiling, bundlers, the CSS pipeline and, once the code runs, how React turns JSX into DOM updates.
One command, many tools
A build tool is a toolchain: separate programs, each doing one job, wired together by a configuration you may never see. The jobs are the same everywhere; setups differ in which tool fills each slot, and in which slots they leave empty on purpose.
- Create React App wrapped webpack and Babel in one package,
react-scripts, with a fixed configuration. Changing it meantnpm run eject, which copied hundreds of lines of webpack config into your project for good. - Vite doesn't bundle your source in development: the browser loads each file as a native ES module, transformed on request. Only dependencies are pre-bundled. Production builds are bundled, with Rolldown since Vite 8.
- Next.js builds client and server code together, so its build also decides what renders on the server. Turbopack is its default bundler in both
next devandnext build. - Stripping types is not checking them. Babel, SWC and Oxc delete type annotations without reading them, which is why they are fast; only
tsc(or a plugin running it) finds type errors. Vite never type-checks; Next.js does it duringnext build. - Most of these tools are written in Rust or Go (SWC, Oxc, Rolldown, Turbopack, Lightning CSS, esbuild) rather than JavaScript, which is where most of the speed-ups of the last few years came from.
Try it: pick a setup and see which tool does each job
npx create-react-app: One package (react-scripts) that wraps webpack 5 and Babel with a fixed configuration.
Deprecated. Sunset by the React team on 14 February 2025: no new features, and new apps are pointed to a framework or to Vite, Parcel or Rsbuild.
Resolve imports
- Development
- webpack 5
- Production build
- webpack 5
Transform TypeScript and JSX
- Development
- Babel (babel-preset-react-app)
- Production build
- Babel (babel-preset-react-app)
Type-check
- Development
- fork-ts-checker-webpack-pluginRuns in a separate process and shows errors in the browser overlay.
- Production build
- fork-ts-checker-webpack-pluginA type error fails the build.
Process CSS
- Development
- css-loader + PostCSS (+ sass-loader)style-loader injects each file as a <style> tag from JavaScript.
- Production build
- css-loader + PostCSS (+ sass-loader)mini-css-extract-plugin writes real .css files.
Bundle
- Development
- webpack 5, in memory
- Production build
- webpack 5, to build/
Minify
- Development
- Not minified
- Production build
- Terser (JS), cssnano (CSS)
Serve
- Development
- webpack-dev-server
- Production build
- Any static file hostThe build is plain files.
Hot update
- Development
- webpack HMR + React Fast Refresh
- Production build
- Not neededA production build has no running dev server.
| Job | Development | Production build |
|---|---|---|
| Resolve imports | webpack 5 | webpack 5 |
| Transform TypeScript and JSX | Babel (babel-preset-react-app) | Babel (babel-preset-react-app) |
| Type-check | fork-ts-checker-webpack-pluginRuns in a separate process and shows errors in the browser overlay. | fork-ts-checker-webpack-pluginA type error fails the build. |
| Process CSS | css-loader + PostCSS (+ sass-loader)style-loader injects each file as a <style> tag from JavaScript. | css-loader + PostCSS (+ sass-loader)mini-css-extract-plugin writes real .css files. |
| Bundle | webpack 5, in memory | webpack 5, to build/ |
| Minify | Not minified | Terser (JS), cssnano (CSS) |
| Serve | webpack-dev-server | Any static file hostThe build is plain files. |
| Hot update | webpack HMR + React Fast Refresh | Not neededA production build has no running dev server. |
Italic entries are jobs the setup deliberately leaves undone in that mode.
Do: Know which tool fills each slot in your project, so you look in the right documentation when a build fails.
Avoid: Starting new projects on Create React App: it is deprecated and its dependencies no longer get updates.
Do: Run
tsc --noEmitin CI when your bundler doesn't type-check.Avoid: Assuming a green dev server means the code has no type errors.
Read more:React: Sunsetting Create React App(opens in a new tab)Vite: Why Vite(opens in a new tab)Next.js: Turbopack(opens in a new tab)webpack: Concepts(opens in a new tab)
The stages: resolve, transform, bundle, optimize, emit
Every bundler runs the same five stages. It starts at an entry file, follows imports to build a module graph, transforms each module into plain JavaScript, joins the modules into bundles, optimizes them, and writes files to disk.
- Resolving turns an import string into a file. A relative path (
./App) tries extensions until one exists; a bare name (react-dom/client) is looked up innode_modulesthrough the package'sexportsfield. - Resolve and transform alternate, file by file: a bundler can't see a file's imports until it has transformed it. The JSX transform even adds an import (
react/jsx-runtime) that isn't in your source. - A non-JavaScript file (CSS, an image, JSON) only enters the graph through a loader or plugin that turns it into a module. Without one, webpack fails with "You may need an appropriate loader to handle this file type".
- Bundling wraps each module in a function and adds a small runtime (a
require()and a module cache) that links them. Production builds then scope-hoist ES modules into one function, which removes that overhead. - Tree shaking works because ES module imports and exports are static: the bundler can prove
slugifyis never used without running anything.
Try it: step a four-file app through the build
Stage 1 of 5: Resolve. Starting from the entry, find the file behind every import, building a graph of modules.
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles.css";
createRoot(document.getElementById("root")!).render(<App />);
src/index.tsx (entry)
├── react-dom/client → node_modules/react-dom/client.js
│ └── react → node_modules/react/index.js
├── react/jsx-runtime → node_modules/react/jsx-runtime.js
├── ./App → src/App.tsx
│ ├── react/jsx-runtime (already in the graph)
│ └── ./utils → src/utils.ts
└── ./styles.css → src/styles.css
(config)"src/index.tsx"resolves tosrc/index.tsx
The entry point, from the build configuration.
src/index.tsx"react-dom/client"resolves tonode_modules/react-dom/client.js
A bare package name: find react-dom in node_modules, then read the "./client" entry of its package.json "exports".
src/index.tsx"./App"resolves tosrc/App.tsx
Relative, without an extension: try the configured extensions in order until a file exists.
src/index.tsx"./styles.css"resolves tosrc/styles.css
Relative, with an extension: the file itself. Not JavaScript, so it needs a CSS transform.
src/App.tsx"./utils"resolves tosrc/utils.ts
Relative: .tsx doesn't exist, .ts does.
src/App.tsx, src/index.tsx"react/jsx-runtime"resolves tonode_modules/react/jsx-runtime.js
Added by the JSX transform, so it only appears after the file is transformed: resolving and transforming alternate, file by file.
react-dom/client.js"react"resolves tonode_modules/react/index.js
Dependencies have imports too; the graph keeps going into node_modules.
Simplified: the outputs are written out by hand to match what the tools emit for this sample. React's own code is left out, so the sizes (measured in your browser) cover the sample code only.
Do: Import from ES module builds of packages, and import only what you use, so tree shaking can remove the rest.
Avoid: Barrel files that re-export a whole library from one index: the bundler may have to read and keep far more than you import.
Read more:webpack: Module resolution(opens in a new tab)Node.js: Package entry points (exports)(opens in a new tab)webpack: Tree shaking(opens in a new tab)
Development and production builds
The same source builds two ways. A development build is tuned for how fast it rebuilds and how easy it is to debug; a production build is tuned for how little the browser has to download and run. Most surprises ("it works locally", a huge dev bundle, warnings that vanish in production) come from that difference.
- The build replaces
process.env.NODE_ENVwith a string constant (webpack'sDefinePlugin, Vite'sdefine). The minifier then folds the condition and deletes branches that can never run. - React itself ships two builds, picked by the same flag. The development build checks hooks, keys and props and warns; the production build skips all of that, which makes it smaller and faster.
- Only
process.env.NODE_ENVand variables you expose on purpose (NEXT_PUBLIC_,VITE_) are replaced. Anything replaced ends up as plain text in the bundle, so never expose a secret this way. - Source maps let the browser's developer tools show your original files. Development usually inlines them for speed; production writes separate
.mapfiles that you can keep off the public server and upload to an error tracker instead.
Try it: switch the sample between development and production
- File name
- main.js: the same name every time
- Minified
- No: readable, with module paths kept
- Source map
- Inlined into the file as a data: URL, for fast rebuilds
- Development-only checks
- Kept: your console.log, and React's development build with its warnings
- On save
- Rebuild only what changed, in memory, then hot-update the page
- Size of the sample's code
- measuring… (the other mode: measuring…)
How the mode flag removes a branch
- 1. Source
if (process.env.NODE_ENV !== "production") { console.log(…) } - 2. Replace the flag
if ("development" !== "production") { console.log(…) } - 3. Fold the constant
if (true) { console.log(…) } - 4. Remove dead code
console.log(…) // kept: it always runs
(() => {
var modules = {
"./src/index.tsx": (module, exports, require) => {
var _jsxRuntime = require("react/jsx-runtime");
var _client = require("react-dom/client");
var _App = require("./src/App.tsx");
require("./src/styles.css");
(0, _client.createRoot)(document.getElementById("root")).render(
(0, _jsxRuntime.jsx)(_App.App, {})
);
},
"./src/App.tsx": (module, exports, require) => {
exports.App = App;
var _jsxRuntime = require("react/jsx-runtime");
var _utils = require("./src/utils.ts");
function App({ price = 42 }) {
if (process.env.NODE_ENV !== "production") {
console.log("App rendered with", price);
}
return (0, _jsxRuntime.jsx)("p", {
className: "price",
children: (0, _utils.formatPrice)(price)
});
}
},
"./src/utils.ts": (module, exports) => {
exports.formatPrice = formatPrice;
exports.slugify = slugify;
function formatPrice(value) {
return "$" + value.toFixed(2);
}
function slugify(text) {
return text.toLowerCase().replace(/\s+/g, "-");
}
},
"./src/styles.css": () => {
/* injects the <style> tag */
},
"react/jsx-runtime": (module) => {
/* React's own code, not shown */
},
"react-dom/client": (module) => {
/* React DOM's own code, not shown */
},
};
var cache = {};
function require(id) {
if (cache[id]) return cache[id].exports;
var module = (cache[id] = { exports: {} });
modules[id](module, module.exports, require);
return module.exports;
}
require("./src/index.tsx");
})();
//# sourceMappingURL=data:application/json;base64,…
Simplified: the sample app from the stages demo, with React's own code left out. Sizes are measured in your browser.
Do: Measure performance and bundle size on a production build (
npm run build, then serve it), never on the dev server.Avoid: Reading a secret through process.env in client code: the build inlines its value into a file anyone can download.
Read more:webpack: Production(opens in a new tab)Vite: Env variables and modes(opens in a new tab)Next.js: Environment variables(opens in a new tab)
Content hashes and long-term caching
Production file names carry a content hash, such as main-3f2a9c1d.js. The name changes exactly when the content does, so the server can tell browsers to cache the file for a year: a new deploy gets new names and can't be served stale.
- The HTML is the one file that must not be cached long: it holds the current names. Serve it with
Cache-Control: no-cacheand the hashed files withmax-age=31536000, immutable(see HTTP caching). - Splitting rarely-changing dependencies into a vendor chunk means a change to your code doesn't make every visitor download React again.
- The runtime that loads lazy chunks contains their hashed names. Left inside
main, editing a lazy page renamesmaintoo; split into its own small chunk (webpack'sruntimeChunk), the change stays local. - Hashes must be deterministic: the same input must give the same name on every build machine, or each deploy busts the cache for no reason.
Try it: edit a file and see which names change
Hashing…
Hashes are real (SHA-256 of each chunk's content, computed in your browser); the chunks are a simplified model. Bundlers use faster hash functions, such as xxHash, but the rule is the same.
Do: Cache hashed assets forever and the HTML not at all.
Avoid: Query-string versions such as
main.js?v=2: some caches ignore them, and you must bump them by hand.
Read more:webpack: Caching(opens in a new tab)web.dev: HTTP cache(opens in a new tab)
What this page loaded
This site is a Next.js 16 app built with Turbopack. Everything above happened to its source too: the files below are the chunks the build emitted, and your browser recorded each download in its Resource Timing buffer.
- Next.js splits code per route: this page loads the shared framework chunks plus the chunks for its own demos, not the code of every other page.
- The demos on this page are client components; the text is rendered on the server and sent as HTML, so it adds no JavaScript.
- Transferred is what came over the network (compressed, plus headers); Decoded is what the browser had to parse. Both matter: one costs bandwidth, the other CPU time.
Try it: list the build files this page loaded
Reads the browser's own Resource Timing entries for this page.
Do: Check what a page really loads in the Network panel or with Resource Timing, on a production build.
Avoid: Judging bundle size from the size of your source files: dependencies and the framework usually outweigh them.
Read more:MDN: PerformanceResourceTiming(opens in a new tab)Next.js: Package bundling(opens in a new tab)