Topic 23.2
Environment Variables and Configuration
In one line
Configuration such as an API address or a feature switch reaches a React app either at build time, when the build tool pastes VITE_ or NEXT_PUBLIC_ values into the bundle, or at runtime, when the app loads a config file as it starts; anything that reaches the browser is public, so secrets never belong there.
Think of it like this
A printed restaurant menu versus a chalkboard of today's specials. The printed menu (build-time config) is fixed when it's printed: to change a price you must print new menus. The chalkboard (runtime config) can be rewritten every morning without reprinting anything. And both are on public display: you would never write the safe's combination on either.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Configuration
- Settings that change between places the app runs, like the API address or feature switches.
- Environment variable
- A named value given to a program from outside, such as
VITE_API_URL=https://.... - Environment
- One place the app runs: your laptop, staging, production.
- Build-time config
- Values pasted into the bundle when it's built. Changing them needs a new build.
- Runtime config
- Values the app loads when it starts, such as
/config.json. Changing them needs no new build. - Mode (Vite)
- A name passed with
--modethat picks which.env.[mode]files load and setsimport.meta.env.MODE. - VITE_ / NEXT_PUBLIC_
- Name prefixes that mark a variable as safe to put in the browser bundle.
- Secret
- A value that grants access, like a password or private API key. It must never reach the browser.
Step by step
01Write the .env files
Put shared, non-secret defaults in .env (committed), your personal overrides in .env.local (git-ignored), and environment-specific values in .env.staging or .env.production.
# .env (committed, used by every mode)
VITE_API_URL=https://api.example.com
VITE_FEATURE_CHAT=off
# .env.staging (used only with --mode staging)
VITE_API_URL=https://staging-api.example.com
# NOT like this: without the prefix it's not exposed, and with it it would be public
DB_PASSWORD=never-in-a-frontend-env-file02Read them in code, with types
Read each variable in one place and export a typed object, so the rest of the app doesn't sprinkle import.meta.env everywhere. In TypeScript, declare the variables so typos are caught.
// src/vite-env.d.ts
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_FEATURE_CHAT: "on" | "off";
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
// src/config.ts
export const config = {
apiUrl: import.meta.env.VITE_API_URL,
chatEnabled: import.meta.env.VITE_FEATURE_CHAT === "on",
isProd: import.meta.env.PROD,
};03See what the build pasted in (real output)
With .env holding VITE_API_URL and DB_PASSWORD, a real vite build turned console.log("API:", import.meta.env.VITE_API_URL, "secret:", import.meta.env.DB_PASSWORD, "mode:", import.meta.env.MODE, import.meta.env.DEV, import.meta.env.PROD) into the line below. Then vite build --mode staging with a .env.staging changed the URL and the mode.
// vite build
console.log(`API:`,`https://api.example.com`,`secret:`,void 0,`mode:`,`production`,!1,!0)
// vite build --mode staging
console.log(`API:`,`https://staging-api.example.com`,`secret:`,void 0,`mode:`,`staging`,!1,!0)04Know the dynamic-access trap
If you read import.meta.env[name] with a variable name, Vite can't know which one you want, so it pastes the whole public env object into the bundle (real output below). It's still only VITE_ and built-in values, but every one of them is now in that chunk.
// source: console.log("DYN", import.meta.env[k])
console.log(`DYN`,{BASE_URL:`/`,DEV:!1,MODE:`production`,PROD:!0,SSR:!1,VITE_A:`env`,VITE_B:`local`}[e])05Or build once and load config at runtime
For one artifact deployed to many environments, the server provides /config.json and the app fetches it before the first real render. The live example below shows the startup sequence.
Try it yourself
- 1
Expose the secret by accident
In the first example, rename
DB_PASSWORDtoVITE_DB_PASSWORDin bothsourceandenv. Predict the bundle line before running. This is exactly how real keys leak into public bundles. - 2
Add a production-only file
In "Which .env file wins?", put
{ VITE_FEATURE_CHAT: "off" }into.env.production. Predict the production line. Does.env.productionbeat.env.local? (Yes: mode files come after.local.) - 3
Fail loudly on a broken config
In the runtime config example, change
configUrlto"data:application/json,{oops". What happens? Add a.catchthat renders an error message with a Retry button instead of leaving "Loading settings…" forever.
Code & diagrams
A model of what `vite build` does (the real output is in the walkthrough). The values are pasted in as text, so the code that ships contains the URL itself. `DB_PASSWORD` has no `VITE_` prefix, so it becomes `undefined` (real Vite writes `void 0`). Switch the mode to `development` to see `DEV` and `PROD` flip.
Preview
Console
Nothing logged yet.Expected console output
const api = "https://api.example.com";
const pw = undefined;
console.log("production", true);
--- staging build
const api = "https://api.example.com";
const pw = undefined;
console.log("staging", true);This copies the order a real Vite 8 build used when tested with five variables spread over `.env`, `.env.local`, `.env.staging`, `.env.staging.local` and the shell. Notice the surprise in a real team: `.env.local` on one developer's laptop turns chat on in their production build, but not in CI's. Build releases in CI, never on a laptop.
Preview
Console
Nothing logged yet.Expected console output
production: {"VITE_API_URL":"https://api.example.com","VITE_FEATURE_CHAT":"on","VITE_SENTRY_ENV":"production"}
staging: {"VITE_API_URL":"https://staging-api.example.com","VITE_FEATURE_CHAT":"on","VITE_SENTRY_ENV":"staging"}
staging + shell: {"VITE_API_URL":"https://staging-api.example.com","VITE_FEATURE_CHAT":"off","VITE_SENTRY_ENV":"staging"}The same bundle works in every environment: only `config.json` differs. `cache: "no-store"` makes sure a changed config is picked up at once. This is one of the rare places where calling `root.render` a second time is the right tool: the config is ready before any component needs it, so no component has to handle "config not loaded yet".
Preview
Console
Nothing logged yet.Expected console output
1. page shell shown, loading config
2. config loaded: staging https://api.eu.example.com
3. screen: Talking to https://api.eu.example.com (staging)💬 Chat is on// .env.local
// PAYMENTS_SECRET=sk_live_... server only
// NEXT_PUBLIC_ANALYTICS_ID=abcdefghijk inlined into the browser bundle at next build
// app/page.tsx (a Server Component: runs on the server)
export default async function Page() {
const res = await fetch("https://payments.example.com/balance", {
headers: { Authorization: `Bearer ${process.env.PAYMENTS_SECRET}` }, // fine: never sent to the browser
});
const { balance } = await res.json();
return <Balance amount={balance} />;
}
// app/analytics.tsx
"use client";
export function Analytics() {
// Replaced with "abcdefghijk" at build time.
// process.env.PAYMENTS_SECRET here would be undefined in the browser.
return <Script data-site={process.env.NEXT_PUBLIC_ANALYTICS_ID} src="/a.js" />;
}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
Forget the prefix
Write API_URL=https://api.example.com in .env and read import.meta.env.API_URL in a component.
Break #2
Put a secret behind VITE_
Add VITE_OPENAI_KEY=sk-... so the browser can call a paid AI API directly.
Break #3
Change a variable after building
Build a Docker image once, then start it in production with -e VITE_API_URL=https://api.example.com.
Myth vs fact
Myth
.env files keep secrets safe.
Fact
They keep secrets out of your source code, which is good, but any VITE_ or NEXT_PUBLIC_ value ends up in a public JavaScript file.
Myth
--mode staging makes a development build.
Fact
Mode only picks env files and sets MODE. vite build --mode staging is still a minified production build with PROD true.
Myth
Changing an env var on the hosting dashboard updates the live site.
Fact
For build-time variables you must redeploy (rebuild). Only runtime config or server-side variables change without a new build.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Vite inlines
import.meta.env.Xby static replacement; dynamic access (import.meta.env[key]) inlines the entire public env object. Next.js inlines only directprocess.env.NEXT_PUBLIC_Xreferences and doesn't inline dynamic lookups at all. - ▸
"Build once, deploy many" is the norm for container platforms: one image tested in staging is promoted to production unchanged. A tiny entrypoint script that writes
config.json(or a<script>window.__CONFIG__=...</script>inindex.html) from environment variables gives runtime config with zero React changes. - ▸
Validate config at startup (a schema with Zod or a few
ifchecks) and fail loudly. A missing variable discovered in production asundefined/productsis far harder to debug than an error at boot. - ▸
loadEnv(mode, process.cwd(), "")invite.config.jsreads all variables (not justVITE_) for use in the config itself, for example to set a dev proxy target. Those values stay in Node and aren't exposed unless you put them indefine.
Remember this
- 1
Configuration is everything that changes between places your app runs but isn't code: the API address, an analytics key, which features are switched on, the name of the environment (
development,staging,production). An environment is one place the app runs. Environment variables are named values (VITE_API_URL=https://api.example.com) that the operating system or a.envfile gives to a program when it starts. - 2
Build-time config in Vite. During
vite build, everyimport.meta.env.VITE_SOMETHINGin your code is replaced with the literal value from the environment, and the minifier then works with that constant. Only variables whose names start with **VITE_** are exposed; any other name (DB_PASSWORD) becomesundefinedin the bundle. That prefix is a safety catch, not a lock: whatever you prefix withVITE_is pasted into a JavaScript file that every visitor downloads. You can change the prefix with theenvPrefixoption, but never set it to an empty string. - 3
Built-in values. Vite always provides
import.meta.env.MODE(the mode name),DEVandPROD(booleans),BASE_URL(thebaseoption, usually/) andSSR. Mode is chosen with--mode:vite(dev) defaults todevelopmentandvite buildtoproduction. A realvite build --mode staginggivesMODE"staging"whilePRODstaystrue, because it's still a production build. Mode chooses which.envfiles load; it doesn't change how the code is optimised. - 4
**
.envfiles and their priority.** Vite reads.env(all modes),.env.local(all modes, your machine only),.env.[mode]and.env.[mode].local, in that order, and later files win. Variables already set in the shell or CI when Vite starts win over every file. The new Vite template's.gitignorecontains*.local, so.env.localfiles stay out of git. Changes to.envfiles need a restart of the dev server and a new build: the values are read once, when Vite starts. - 5
**Next.js does the same with
NEXT_PUBLIC_.** In Next.js,process.env.NEXT_PUBLIC_ANALYTICS_IDis inlined into the browser bundle atnext build; variables without the prefix exist only on the server (Server Components, Route Handlers, Server Actions), which is where secrets belong. Only direct references are inlined:process.env[name]orconst env = process.env; env.NEXT_PUBLIC_Xare not. After the build, changing aNEXT_PUBLIC_value does nothing until you build again. - 6
Per-environment builds vs runtime config. With build-time config you build once per environment (
vite build --mode staging,vite build --mode production). That's simple but means the file you tested in staging is not the file you ship. The alternative is build once, deploy many: the bundle contains no environment values, and at startup the app fetches a small **/config.json** (written by the server, the container's start script or the hosting platform) before rendering. It costs one request at startup, and it lets one Docker image run everywhere (Topic 23.3). - 7
Secrets never go to the browser. An API key that grants access to paid services or private data, a database password, a signing secret: if the browser has it, every visitor has it. Keep secrets on a server (a Next.js server function, your own API, a hosting platform's serverless function) and let the browser call that server. Values that are designed to be public (a Stripe publishable key, a Sentry DSN, an analytics site ID, a map key restricted to your domain) are fine to expose.
Explain it without notes
What is the difference between build-time and runtime configuration in a React app?
Why do Vite and Next.js require the VITE_ and NEXT_PUBLIC_ prefixes?
In what order does Vite load .env files, and which wins?
How would you keep an API secret safe in a React app that needs to call that API?
Practice
Write a config.js module for a Vite app that reads VITE_API_URL and VITE_FEATURE_CHAT, throws a clear error if the URL is missing, and exports { apiUrl, chatEnabled }.
Write the package.json scripts to build the same app for staging and production with Vite modes.
Trade-offs
- ↔
Build-time config is simplest and lets dead code vanish (a disabled feature can be removed from the bundle), but needs a build per environment. Runtime config allows one artifact everywhere but adds a request and a loading state at startup.
- ↔
Feature flags at build time are free at runtime but need a deploy to flip; a runtime flag service can flip them instantly but adds a dependency and must be fast and fail-safe.
Done when you can
I can read
VITE_variables and the built-inMODE,DEVandPRODvalues in a Vite app.I know the
.envfile order and that the shell environment wins.I can explain why anything in the browser bundle is public, and where secrets go instead.
I can build per environment with
--modeor load runtime config fromconfig.json.I know how
NEXT_PUBLIC_works and that it's frozen atnext build.