Build tooling and environment variables
What `vite build` actually does to your React code, how the dev server differs from the production bundle, how `import.meta.env` and the `VITE_` prefix work, why a front-end environment variable can never hold a secret, and what lands in `dist/` on Cloudflare Pages.
12 cards · 6 quiz questions · 10 min read
Your React front end has a vite.config.ts you have never opened, an .env file with a few VITE_ lines in it, and a Cloudflare Pages project whose build settings say npm run build and dist. It works. Then one day the deploy is a blank page, or an agent helpfully moves an API key into .env “so it isn’t hardcoded”, and you need to know what this machinery actually does. Short version: the build turns your source into static files, and anything it can see ends up in those files, visible to everyone.
What vite build does
Four distinct jobs, worth separating because they fail in different ways.
Transpiling. Your .tsx files are not JavaScript. TypeScript annotations and JSX get rewritten into plain function calls and plain JavaScript that browsers understand. Vite does this per file, and — this matters — it does not type-check while doing it. Vite’s documentation is explicit: it performs transpilation on .ts files and does not perform type checking, because checking needs the whole module graph while transpiling works one file at a time.
Bundling. Your app might be 400 modules. Shipping 400 files means 400 requests, in waves, because each file’s imports are only discovered once it arrives. The build combines them into a few files instead.
Tree-shaking. Exports that nothing imports are dropped. This works through static analysis of ES module imports, which is why it sometimes doesn’t: a library without an ES module build, an import for side effects only, or a whole-namespace import all defeat it.
Hashing. Output filenames get a content hash — assets/index-C4rDf9aQ.js — and the HTML is rewritten to point at them. Any change to the contents means a new name, so those files can be cached almost forever while the tiny index.html stays uncached.
Everything lands in dist/ by default, with assets under dist/assets/.
The consequence of the no-type-checking point is worth spelling out:
// Before: a green CI that has never checked a single type.
{ "scripts": { "build": "vite build" } }
// After: type errors fail the build.
{ "scripts": { "build": "tsc --noEmit && vite build" } }
Dev server versus production build
In development, Vite doesn’t bundle at all. It serves your files as native ES modules over HTTP and transforms each one as the browser asks for it — which is why vite dev starts instantly regardless of project size. For production it bundles, because, as the Vite docs put it, nested imports cause additional network round trips.
HMR (Hot Module Replacement) swaps an edited module into the running page without a reload, keeping component state. Its failure mode is drift: state accumulated across twenty edits makes a bug appear or vanish for reasons unrelated to your change. When behaviour stops making sense, hard-reload before you debug further.
The wider point is that “it works in dev” is weak evidence. The two paths are different pipelines, and a class of bugs exists only after a build: a side effect tree-shaken away, a base path, a VITE_ variable that was in your local .env and not in CI. To check a real build:
npm run build # produces dist/
npm run preview # serves dist/ locally, as a static site
import.meta.env and the VITE_ prefix
import.meta.env is Vite’s environment object. The key word is build time: these are not read when the page runs, they are substituted into the JavaScript as literal values while bundling.
Five built-ins are always present: MODE, BASE_URL, PROD, DEV and SSR. Your own values need the prefix:
# .env
VITE_API_BASE_URL=https://api.iff.example.com # exposed to the browser
SURVEY_DB_PASSWORD=hunter2 # ignored by the client build
const base = import.meta.env.VITE_API_BASE_URL; // "https://api.iff.example.com"
const other = import.meta.env.SURVEY_DB_PASSWORD; // undefined — no prefix
if (import.meta.env.DEV) console.log("dev only");
Only variables starting with VITE_ are copied into the client bundle. A variable that reads undefined in the browser has almost always lost its prefix. The prefix itself is configurable via envPrefix, so check the config before assuming.
The .env files load in a fixed precedence, highest first: existing process environment variables, .env.[mode].local, .env.[mode], .env.local, then .env. vite build runs in production mode by default, so .env.production beats .env. The .local files should be git-ignored.
Why front-end env vars can never be secrets
This follows directly from build-time substitution. Put a key in VITE_API_KEY and it becomes a string literal in a file served to every visitor:
# After any build, this finds it:
grep -ro "sk_live_[A-Za-z0-9]*" dist/
# dist/assets/index-C4rDf9aQ.js:sk_live_51Hx...
Vite’s own documentation states that VITE_* variables should not contain sensitive information. Moving a key from a hardcoded string into .env changes nothing about its exposure — it only makes the exposure harder to notice in review. Setting it in the Cloudflare Pages dashboard instead of a file changes nothing either; the dashboard value is injected into the build, and the build bakes it in.
The fix is architectural. The secret lives on a server, and the browser calls that server:
// Before: the browser holds the credential.
const res = await fetch("https://api.provider.com/v1/panels", {
headers: { Authorization: `Bearer ${import.meta.env.VITE_PROVIDER_KEY}` },
});
// After: the browser calls your own endpoint; the key stays server-side.
const res2 = await fetch("/api/panels", { credentials: "include" });
That endpoint is a FastAPI route or a Cloudflare Worker holding the key as a real server secret. And any key that has already shipped in a bundle should be treated as leaked and rotated — not quietly removed.
Bundle size and code splitting
Vite warns when an output chunk exceeds build.chunkSizeWarningLimit, which defaults to 500 kB before compression. It is advice, not an error, and raising the limit is the one response that helps nobody. The warning usually means a heavy dependency is loading on first paint.
Code splitting is the answer: a dynamic import() makes the imported module and its dependencies a separate chunk, fetched on demand.
// Before: the chart library ships to everyone, on first paint.
import { CrosstabChart } from "./CrosstabChart";
// After: its own chunk, fetched when the tab is opened.
const CrosstabChart = lazy(() => import("./CrosstabChart"));
function Tabs({ tab }: { tab: string }) {
return (
<Suspense fallback={<Spinner />}>
{tab === "charts" ? <CrosstabChart /> : <ResponseTable />}
</Suspense>
);
}
Vite rewrites code-split dynamic imports with a preload step so the chunk’s own dependencies fetch in parallel rather than in a chain, and it extracts that chunk’s CSS into a separate file loaded alongside it. Splitting only pays where the split code is genuinely optional — a rarely-opened tab, an admin screen, an export dialog. Splitting something every visit needs just adds a round trip.
The base path
base is the public path Vite writes into every generated asset URL — in the HTML, in CSS, in JS-imported URLs. Default /, correct for a site at a domain root.
If the app is served from a subpath and base is wrong, the symptom is specific and recognisable: the page loads, the screen is blank, and the network tab shows 404s for /assets/index-*.js. The HTML asked for the root path; the files are one directory down.
// vite.config.ts — for https://tools.example.com/fieldwork/
export default defineConfig({
base: "/fieldwork/", // or "./" for a fully relative build
});
import.meta.env.BASE_URL carries this value at runtime, which is what a router needs so its links agree with the assets.
What lands in dist/ on Cloudflare Pages
Pages runs your build command and deploys the contents of the build output directory. For a Vite React app the documented preset is build command npm run build, output directory dist. What ships is static files and nothing else:
dist/
index.html # small, uncached, points at the hashed files
assets/index-C4rDf9aQ.js # your bundled app
assets/index-9fQ2p1Zr.css
assets/CrosstabChart-7bK...js # a split chunk
favicon.svg # copied verbatim from public/
Two things follow. First, there is no server process — so there is no runtime environment variable for the front end, only what was compiled in. If you need a value that varies per request, or a secret, you need a Worker or a Pages Function, which is a different thing from your React bundle. Second, Pages injects build-time variables you can use: CI, CF_PAGES, CF_PAGES_BRANCH, CF_PAGES_COMMIT_SHA, CF_PAGES_URL — handy for stamping a build with its commit.
One trap specific to running some sites from Git and some with wrangler pages deploy: an upload of a locally-built dist/ bakes in your local .env. Whatever was on your machine, including a staging API URL, is what visitors get.
What to check in your own code
- Run
grep -rIn "VITE_" src/and ask, for each one, whether you would be happy posting that value publicly. If not, it needs to move behind a server. - After a build, run
grep -rEo "sk_[a-z]+_[A-Za-z0-9]{8,}|AKIA[0-9A-Z]{16}" dist/and check nothing comes back. - Confirm
.env.localand.env.*.localare in.gitignore, and checkgit log -p -- .envfor anything committed by accident. - Check the build script actually type-checks:
tsc --noEmit(orastro check) beforevite build. - Compare the Cloudflare Pages output directory setting against Vite’s
outDir. A mismatch deploys an empty site. - If the site is served from a subpath, confirm
basematches it, and that the router usesimport.meta.env.BASE_URL. - Look at the build log’s chunk list. If the biggest first-load chunk is a library used on one screen, that is a
lazycandidate. - Check
vite.config.tsfor a raisedchunkSizeWarningLimit— someone silenced a warning rather than answering it. - For any site deployed with
wrangler pages deploy, confirm you know which.envthe uploaded build was made with.
A React dashboard calls an internal API with a key read from `import.meta.env.VITE_API_KEY`, set in the Cloudflare Pages build settings. How exposed is that key?
Sources
- Building for Production — Vite website What the production build emits, hashed asset filenames, browser targets and the base public path.
- Env Variables and Modes — Vite website The import.meta.env built-ins, the VITE_ prefix rule, .env file precedence, and the warning that VITE_ variables must not contain sensitive information.
- Features — Vite website Vite transpiles .ts files and does not type-check them; dynamic imports are code-split and preloaded, with per-chunk CSS extracted.
- Build configuration — Cloudflare Pages website Build command and build output directory (dist for a Vite React app), and the CF_PAGES variables injected into the build.