Command Palette

Search for a command to run...

Hectal
PHASE 23Advanced ~26 min· topic 2 of 6

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 --mode that picks which .env.[mode] files load and sets import.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 / .env.stagingwhole filebash
# .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-file

02Read 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/config.ts + src/vite-env.d.tswhole filetsx
// 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.

dist/assets/index-<hash>.js (excerpts)whole filejs
// 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.

dist/assets/index-<hash>.js (excerpt)whole filejs
// 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.

Or build once and load config at runtimediagram
Rendering diagram…

Try it yourself

  1. 1

    Expose the secret by accident

    In the first example, rename DB_PASSWORD to VITE_DB_PASSWORD in both source and env. Predict the bundle line before running. This is exactly how real keys leak into public bundles.

  2. 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.production beat .env.local? (Yes: mode files come after .local.)

  3. 3

    Fail loudly on a broken config

    In the runtime config example, change configUrl to "data:application/json,{oops". What happens? Add a .catch that renders an error message with a Retry button instead of leaving "Loading settings…" forever.

Code & diagrams

What import.meta.env becomes in the bundle New tab

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

Press Run to see the page.

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);
Which .env file wins? New tab

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

Press Run to see the page.

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"}
Runtime config: load config.json before rendering React 19+ New tab

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

Press Run to see the page.

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
Next.js: server-only vs NEXT_PUBLIC_tsx
// .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.

terminal
$ Browser console
── what you'll see ──
GET http://localhost:5173/undefined/products 404 (Not Found)

Break #2

Put a secret behind VITE_

Add VITE_OPENAI_KEY=sk-... so the browser can call a paid AI API directly.

terminal
$ Anyone: View source → search the JS bundle
── what you'll see ──
const key = "sk-..." (the full key, readable by every visitor)

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.

terminal
$ Browser
── what you'll see ──
The app still calls the API address that was set when the image was built

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.X by static replacement; dynamic access (import.meta.env[key]) inlines the entire public env object. Next.js inlines only direct process.env.NEXT_PUBLIC_X references 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> in index.html) from environment variables gives runtime config with zero React changes.

  • ▸

    Validate config at startup (a schema with Zod or a few if checks) and fail loudly. A missing variable discovered in production as undefined/products is far harder to debug than an error at boot.

  • ▸

    loadEnv(mode, process.cwd(), "") in vite.config.js reads all variables (not just VITE_) 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 in define.

Remember this

  1. 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 .env file gives to a program when it starts.

  2. 2

    Build-time config in Vite. During vite build, every import.meta.env.VITE_SOMETHING in 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) becomes undefined in the bundle. That prefix is a safety catch, not a lock: whatever you prefix with VITE_ is pasted into a JavaScript file that every visitor downloads. You can change the prefix with the envPrefix option, but never set it to an empty string.

  3. 3

    Built-in values. Vite always provides import.meta.env.MODE (the mode name), DEV and PROD (booleans), BASE_URL (the base option, usually /) and SSR. Mode is chosen with --mode: vite (dev) defaults to development and vite build to production. A real vite build --mode staging gives MODE "staging" while PROD stays true, because it's still a production build. Mode chooses which .env files load; it doesn't change how the code is optimised.

  4. 4

    **.env files 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 .gitignore contains *.local, so .env.local files stay out of git. Changes to .env files need a restart of the dev server and a new build: the values are read once, when Vite starts.

  5. 5

    **Next.js does the same with NEXT_PUBLIC_.** In Next.js, process.env.NEXT_PUBLIC_ANALYTICS_ID is inlined into the browser bundle at next 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] or const env = process.env; env.NEXT_PUBLIC_X are not. After the build, changing a NEXT_PUBLIC_ value does nothing until you build again.

  6. 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. 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

01

What is the difference between build-time and runtime configuration in a React app?

02

Why do Vite and Next.js require the VITE_ and NEXT_PUBLIC_ prefixes?

03

In what order does Vite load .env files, and which wins?

04

How would you keep an API secret safe in a React app that needs to call that API?

Practice

01

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 }.

02

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-in MODE, DEV and PROD values in a Vite app.

  • I know the .env file 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 --mode or load runtime config from config.json.

  • I know how NEXT_PUBLIC_ works and that it's frozen at next build.