Topic 23.1
What a Build Does
In one line
A production build turns your many source files into a few small, fast files in dist/: it transpiles JSX and TypeScript, bundles modules together, removes code nothing uses (tree shaking), minifies what's left, names files by a hash of their content and can write source maps that point back to your original code.
Think of it like this
A school trip with forty children. You can't send each child to the bus one by one through every door of the school (that's the browser fetching hundreds of separate files). So the teacher lines them up, packs them into two buses (bundling), leaves behind the children who aren't going (tree shaking), tells everyone to take only a small backpack (minifying), and keeps a register that says who sits in which seat (the source map), so a lost child can always be traced back to their class.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Build
- The step that turns your source code into the optimised files you put on a server.
- dist/
- The folder a Vite build writes:
index.htmlplus hashed files indist/assets/. - Transpile
- Rewrite code into a form the browser can run, such as JSX into
jsx()calls and TypeScript into JavaScript. - Bundle / chunk
- Bundling combines many modules into a few files. Each output file is a chunk.
- Entry
- The file the bundler starts from, usually
src/main.jsx, reached fromindex.html. - Tree shaking
- Removing exports that nothing imports, so they never reach the browser.
- Side effect
- Code that does something just by being imported, such as changing a global. Bundlers must keep it.
- Minify
- Make code smaller without changing what it does: remove spaces and comments, shorten names.
- Content hash
- A short code made from a file's content and put in its name. Same content, same name; new content, new name.
- Source map
- A
.mapfile that links each spot in the minified output back to your original file, line and column.
Step by step
01The pipeline at a glance
Every build tool does the same steps in roughly this order. Vite 8 uses Rolldown for bundling and Oxc for transpiling and minifying, both written in Rust, which is why builds take hundreds of milliseconds instead of seconds.
02Run the build and read every line
This is the real output for a fresh Vite 8 React app with one extra lazy(() => import("./Chart.jsx")). Each line is one file in dist/, with its size before and after gzip compression (servers send files gzipped or Brotli-compressed, so the gzip number is closer to what users download).
Notice the separate Chart chunk: the dynamic import split it out. The big index chunk is mostly React and React DOM (about 220 kB raw, 69 kB gzipped).
$ npm run build
> app@0.0.0 build
> vite build
vite v8.3.4 building client environment for production...
transforming...
✓ 19 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html 0.45 kB │ gzip: 0.29 kB
dist/assets/index-nqMpL4T3.css 1.78 kB │ gzip: 0.81 kB
dist/assets/Chart-CC2n_YEU.js 0.13 kB │ gzip: 0.15 kB
dist/assets/index-D9PM2J-I.js 221.49 kB │ gzip: 69.38 kB
✓ built in 436ms03Look inside dist/index.html
Vite rewrote your index.html: the <script src="/src/main.jsx"> became the hashed entry chunk, and the CSS was pulled into its own file. This tiny HTML file is the only file whose name never changes, which matters for caching (Topic 23.3).
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>app</title>
<script type="module" crossorigin src="/assets/index-D9PM2J-I.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-nqMpL4T3.css">
</head>
<body>
<div id="root"></div>
</body>
</html>04See tree shaking and minifying in the output
src/money.js exported two functions, formatPrice and convertCurrency; the app imported only formatPrice. Searching the built file finds formatPrice, renamed to d and squeezed onto one line, and no trace of convertCurrency.
// source
export function formatPrice(cents) {
return "Rs " + (cents / 100).toFixed(2);
}
export function convertCurrency(cents, rate) {
return Math.round(cents * rate);
}
// what the build kept (an excerpt of the one-line output)
function d(e){return`Rs `+(e/100).toFixed(2)}05Turn on source maps
Add build.sourcemap to vite.config.js (or pass --sourcemap on the command line). The build prints each map's size. With true, each JS file ends with a comment that tells DevTools where its map is; with "hidden" the maps are written but the comment is left out.
// vite.config.js
export default defineConfig({
plugins: [react()],
build: { sourcemap: "hidden" }, // true | "hidden" | "inline"
});
$ npm run build
...
dist/assets/Chart-CC2n_YEU.js 0.13 kB │ gzip: 0.15 kB │ map: 0.33 kB
dist/assets/index-D9PM2J-I.js 221.49 kB │ gzip: 69.38 kB │ map: 1,045.40 kB
$ ls dist/assets
Chart-CC2n_YEU.js Chart-CC2n_YEU.js.map index-D9PM2J-I.js index-D9PM2J-I.js.map index-nqMpL4T3.css06Test the build, not just the dev server
npm run preview serves dist/ the way a static host would (with one difference you'll meet in Topic 23.3: it also answers unknown paths with index.html). Click through the main flows, open DevTools' Network panel, and check that lazy chunks load when expected.
Try it yourself
- 1
Import one more function
In "Tree shaking by hand", add
"longDate"tomain.jsx's imports fromdates.js. Predict the newbytes:line before running. (longDateandpadare both kept now;padwas already kept.) - 2
Comments don't change the hash
In the minify example, add a comment line in the text box, then change
100to1000. Which edit changes the file name? Why is that exactly what you want for caching? - 3
Build a real app
Create a Vite app (Topic 0.4), add a module with two exports and import only one. Run
npm run buildand searchdist/assets/*.jsfor the unused function's body. Then addbuild: { sourcemap: true }and look at the last line of the JS file.
Code & diagrams
This is the idea a bundler applies to your real modules: start at the entry, follow imports, keep what is reached. `pad` is kept because `shortDate` uses it, even though `main.jsx` never imports it directly. `roundTo` is dropped because only the dropped `convertCurrency` used it.
Preview
Console
Nothing logged yet.Expected console output
kept: formatPrice, shortDate, pad
dropped: convertCurrency, roundTo, longDate
bytes: 760 -> 180Edit the code in the box: comments and spaces don't change the minified output, so the name stays the same; change real code and the name changes. Real minifiers parse the code instead of using regular expressions, and Vite uses a stronger hash, but the result is the same idea.
Preview
Console
Nothing logged yet.Expected console output
export function formatPrice(e){const t=e/100;return "Rs "+t.toFixed(2);}
bytes: 177 -> 72
name: money-06a23g6.js
same code again: money-06a23g6.js
one character changed: money-1r9bnq5.jsThe build turned `import("./Chart.jsx")` into a separate file. The page loads only the entry chunk; the chart's chunk is requested the first time `<Chart />` renders. Open DevTools' Network panel on any real Vite app to see the same thing.
Preview
Console
Nothing logged yet.Expected console output
GET /assets/index-D9PM2J-I.js
files so far: 1
GET /assets/Chart-CC2n_YEU.js
files so far: 2
screen: 📈 Chart (from its own chunk)// source (src/App.jsx)
console.log("dev?", import.meta.env.DEV, "prod?", import.meta.env.PROD);
if (import.meta.env.DEV) {
console.log("debug panel enabled");
}
// after vite build: values replaced, then the minifier removes the dead branch
console.log(`dev?`,!1,`prod?`,!0)Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Deploy the dev server
Run npm run dev -- --host on a server and send users there instead of building.
Break #2
Expect dev error messages in production
Deploy a build where createRoot is given a missing element, then read the production console.
Break #3
Kill tree shaking with a namespace default import
Replace import { debounce } from "lodash-es" with import _ from "lodash" and use _.debounce.
Myth vs fact
Myth
The build checks my TypeScript types.
Fact
Vite strips types without checking them. Run tsc --noEmit (or tsc -b) in CI or as part of your build script to catch type errors.
Myth
Minified code is secure or secret.
Fact
Minifying only makes code shorter. Anyone can read and pretty-print your bundle. Never put secrets in front-end code (Topic 23.2).
Myth
If it works with npm run dev, the build works too.
Fact
Dev and build use different pipelines and different React builds. Test the real build with npm run preview before you deploy.
Myth
Source maps slow the app down.
Fact
Browsers download a map only when DevTools is open. The cost is that public maps reveal your original source, which is why "hidden" maps uploaded to an error tracker are common.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Vite 8 replaced the old esbuild-for-dev, Rollup-for-build pair with Rolldown for both, which removed a whole class of "works in dev, breaks in build" differences.
build.rollupOptionsis now a deprecated alias ofbuild.rolldownOptions, so old config still works with a deprecation notice. - ▸
process.env.NODE_ENVandimport.meta.env.*are replaced with literal values at build time, and the minifier then removes branches that can never run. Libraries rely on this to ship development-only checks for free; React's whole dev build disappears this way. - ▸
Libraries can mark themselves
"sideEffects": falseinpackage.jsonto tell bundlers it's safe to drop any unused module completely. If your own code imports CSS or polyfills for their side effects, list those files insideEffectsor they may be dropped. - ▸
Content hashes depend on the content and the content of what they import, so a change in one module can change several chunk names. Rolldown keeps unchanged chunks' names stable, which is what makes long-term caching effective: users re-download only the chunks that really changed.
Remember this
- 1
Your source code is not what the browser runs. You write JSX, maybe TypeScript, split into dozens of modules that import each other and packages from
node_modules. Browsers can't run JSX or TypeScript, and downloading hundreds of small files one after another is slow. A build tool (Vite, Next.js's Turbopack, webpack) takes your source and produces files the browser can run quickly.npm run buildin a Vite project runsvite build; the result goes into a folder called **dist/** (short for distribution). - 2
Transpiling means rewriting code into a form the target can run, at the same level of language. JSX becomes calls to
jsx()fromreact/jsx-runtime, TypeScript types are stripped (deleted, not checked: runtsc --noEmitseparately to check them, Topic 12.1), and very new syntax can be rewritten for older browsers. Vite 8 transpiles with Oxc, a fast compiler written in Rust. Its default target isbaseline-widely-available: browsers from about early 2023 onwards (Chrome 111+, Firefox 114+, Safari 16.4+), so modern syntax such as?.,??andasync/awaitis left as it is. - 3
Bundling means following every
import, starting from the entry file (src/main.jsx), and combining the modules into a few files called chunks. Vite 8 bundles with Rolldown, a Rust bundler. Each dynamicimport()(whatlazy()uses, Topic 16.8) starts a new chunk that downloads only when that code runs. In the real build below,const Chart = lazy(() => import("./Chart.jsx"))produced a separateChart-CC2n_YEU.jsfile next to the mainindex-D9PM2J-I.js. - 4
Tree shaking (also called dead code elimination) removes exports that nothing imports. ES modules (
import/export, see /javascript) make this possible because imports are fixed at build time: the bundler can see thatformatPriceis imported andconvertCurrencynever is. It keeps only what is reachable from the entry. Tree shaking fails when code has side effects at the top level (code that runs just by being imported, such as changing a global), when you import a whole CommonJS package (require, which can't be analysed the same way), or when a library marks itself"sideEffects": true. That's whyimport { debounce } from "lodash-es"is small andimport _ from "lodash"pulls in the whole library. - 5
Minifying shrinks the code without changing what it does: comments and whitespace go, local variable names become one letter,
truebecomes!0, andif (process.env.NODE_ENV !== "production") { ... }blocks that can never run are deleted. That last step is how you get React's production build: no development warnings, no StrictMode double rendering, smaller and faster. In production, React's error messages are replaced by codes:Minified React error #299; visit https://react.dev/errors/299 for the full message or use the non-minified dev environment for full errors and additional helpful warnings.Vite 8 minifies JavaScript with Oxc and CSS with Lightning CSS. Then every file name gets a content hash (index-D9PM2J-I.js): a short code worked out from the file's bytes, which changes whenever the content changes. Topic 23.3 shows why that lets browsers cache files for a year. - 6
Source maps connect the minified code back to your original files. A
.js.mapfile lists, for each position in the output, the file, line and column it came from. Browser DevTools and error trackers (Topic 23.5) use them to showApp.jsx:12instead ofindex-D9PM2J-I.js:1:48213. Vite writes them only when you ask:build.sourcemap: true(maps plus a//# sourceMappingURL=comment, so anyone's DevTools can load your original source),"hidden"(maps without the comment: for uploading to an error tracker), or"inline"(the map is embedded in the file, which makes it much bigger). - 7
Dev is not build.
npm run devdoesn't bundle your code: the dev server sends each source file to the browser as a native ES module, transpiled on request, and updates only the changed module when you save (HMR, hot module replacement). Packages fromnode_modulesare pre-bundled once so the browser doesn't fetch hundreds of tiny files. The dev server also uses React's development build, with all its warnings. So a bug can appear only in the build (a missing file that the dev server happened to serve, a dead-code assumption, a tree-shaken side effect). Always test the real build withnpm run buildthennpm run preview, which servesdist/locally.
Explain it without notes
What happens, step by step, when you run npm run build in a Vite React app?
What is tree shaking, why do ES modules make it possible, and what stops it from working?
Why do built files have hashes in their names?
What are source maps and what are the three Vite build.sourcemap settings for?
How is npm run dev different from a production build, and why does it matter?
Practice
In the tree-shaking example, dates.js gains an export logImport with { size: 20, uses: [] } that the bundler must keep because it has a side effect. Add a sideEffect: true flag and change shake() so such exports are always kept.
Write a vite.config.js that writes hidden source maps and raises the chunk size warning limit to 800 kB.
Trade-offs
- ↔
More chunks mean better caching and smaller first loads, but more requests and possible waterfalls. Split by route and by heavy optional features, not by every component.
- ↔
Public source maps make production debugging easy for you and for everyone else. Hidden maps uploaded to an error tracker keep your source private at the cost of a build step.
- ↔
A newer
build.targetgives smaller, faster code but drops older browsers. Check who your users are (analytics, Topic 23.5) before changing it.
Done when you can
I can explain transpiling, bundling, tree shaking and minifying in plain words.
I can read Vite's build output and say what each file in
dist/is.I can explain why built files have content hashes in their names.
I can turn on source maps and choose between
true,"hidden"and"inline".I test the production build with
npm run previewbefore deploying.