Environment Variables in React Apps

Understand .env and .env.local files, how Vite exposes environment variables to client code, the distinction between development and production configuration, and why frontend environment variables can never be treated as secrets.

Environment variables let you change configuration — an API base URL, a feature flag, an analytics key — without editing source code, and without committing environment-specific values (like a staging vs. production API endpoint) into version control. In a React app built with a bundler like Vite, environment variables behave very differently from environment variables in a Node.js backend, and misunderstanding that difference is one of the most common sources of accidental secret exposure in frontend projects.

.env files and how Vite loads them

A .env file is a plain-text file of KEY=value pairs read at build/dev-server start time. Vite supports a small hierarchy of these files, loaded in increasing priority: .env (loaded in all environments), .env.local (loaded in all environments, but gitignored by default — for your personal local overrides), .env.[mode] such as .env.development or .env.production (loaded only in that specific mode), and .env.[mode].local (mode-specific and gitignored).

The .local variants exist specifically so each developer (or each CI environment) can override values — a local database URL, a personal feature-flag toggle — without those personal overrides being committed and clobbering teammates' configuration. Vite's default project scaffold already includes .env.local in .gitignore; keep it that way and never force-add a .local file to version control.

Mode is not the same as NODE_ENV, though they're related. vite dev runs in 'development' mode by default and vite build runs in 'production' mode by default; you can also define custom modes (e.g. 'staging') and load them with vite build --mode staging, which will read .env.staging in addition to the base .env file.

• .env — base defaults, safe to commit, loaded in every mode. • .env.local — personal local overrides, gitignored, loaded in every mode. • .env.development / .env.production — mode-specific values, safe to commit if non-secret. • .env.[mode].local — mode-specific personal overrides, gitignored. • Priority: more specific files override less specific ones when the same key appears in both.

The VITE_ prefix and what actually reaches the browser

By default, Vite does not expose your .env variables to client-side code at all — this is a deliberate safety default. Only variables whose name starts with the prefix VITE_ are exposed, via import.meta.env.VITE_SOMETHING, and are statically replaced into your bundled JavaScript at build time. A variable named API_SECRET_KEY in your .env file (no VITE_ prefix) simply will not exist in import.meta.env at all in the browser — Vite silently omits it, which is the correct, safe behavior.

This static replacement matters: import.meta.env.VITE_API_URL is not read at runtime from an environment on some server — it is baked into the literal JavaScript file at build time, the same way a constant would be. This means changing an environment variable's value requires rebuilding the app; a React SPA already deployed to a CDN cannot pick up a new VITE_-prefixed value without a fresh build and redeploy.

Because of this prefix system, it's easy to develop a false sense of security — 'I didn't prefix it with VITE_, so it's safe in my .env file.' That's true only for the specific mechanism of Vite's import.meta.env exposure. If that same unprefixed variable is ever read by build tooling and embedded into the bundle some other way (a webpack DefinePlugin config, a custom build script, a debug console.log of process.env you forgot to remove), it can still leak. The prefix convention is a safety default, not a guarantee against every possible mistake.

Development vs. production configuration

A typical setup has .env.development pointing VITE_API_URL at http://localhost:8080 or a shared dev/staging backend, while .env.production points the same variable at https://api.example.com. Running vite (dev server) loads .env.development automatically; running vite build loads .env.production automatically — you rarely need to pass --mode explicitly for the two most common cases.

For values that must differ per deployment target beyond just dev/prod (e.g. multiple staging environments, or a value that should be injected by your CI/CD pipeline rather than committed at all, like a build number or commit SHA), inject them as actual environment variables in the CI/CD runner (export VITE_BUILD_SHA=$GITHUB_SHA) rather than a committed .env file — Vite's import.meta.env picks up process.env values with the VITE_ prefix at build time automatically, so a committed file isn't required for every value.

Why frontend environment variables are not secrets

This is the single most important idea in this lesson: any value exposed through import.meta.env.VITE_* is compiled directly into the JavaScript files your server sends to every visitor's browser. There is no build step, minifier, or bundler configuration that makes this value confidential — anyone can open browser devtools, view the Sources tab, or simply curl the deployed .js file and read it in plain text (minified, perhaps, but a string literal survives minification unchanged).

This means VITE_STRIPE_SECRET_KEY, VITE_DATABASE_PASSWORD, VITE_JWT_SIGNING_SECRET, or any variable meant to authorize privileged, billable, or destructive server-side operations must never exist — full stop. If a frontend feature seems to require such a secret, the correct architecture is a backend endpoint: your server holds the real secret, and your React app calls your own API, which performs the privileged operation server-side and returns only the specific, minimal result the frontend actually needs.

Some client-exposed values are fine specifically because the service was designed for public client-side exposure and enforces authorization by other means: a Stripe *publishable* key (paired with a private secret key that never leaves the server), a Google Maps browser API key restricted by HTTP referrer/domain in the Google Cloud console, or a public write-only analytics key. The test is not 'does this look like an API key' — it's 'was this specific value explicitly designed by its provider to be safe when publicly visible, and is authorization actually enforced elsewhere.'

Server-side secrets: where real secrets belong

Real secrets — database credentials, private API keys, signing keys, third-party service secrets — belong exclusively in your backend's environment (a Node/Express server, serverless function, or similar), read there with plain process.env.SOME_SECRET (no VITE_ prefix, and ideally not even reachable by your frontend build process's working directory), and are never sent to the browser in any form, not even indirectly through a response that echoes configuration back.

In full-stack setups where a single monorepo contains both a Vite frontend and a Node backend, it's worth being extra careful that the backend's .env file (with real secrets) is not the same file Vite loads, and that Vite's env-loading root doesn't accidentally point at a directory containing backend secrets — Vite will only expose VITE_-prefixed keys to the client, but it's still good hygiene to physically separate frontend and backend env files so a future prefix typo can't turn into an exposure.

.env files and reading them with import.meta.env in Vite

Only VITE_-prefixed keys are exposed to client code; anything else silently does not appear in import.meta.env in the browser. .local files are for personal, uncommitted overrides and are gitignored by Vite's default scaffold. Real secrets (payment provider secret keys, DB passwords, signing keys) never belong in any of these files if this project's frontend is bundled and shipped to a browser.

# .env (committed — safe defaults only, no secrets)
VITE_APP_NAME=SampleApp

# .env.development (committed — dev-only, non-secret config)
VITE_API_URL=http://localhost:8080

# .env.production (committed — prod-only, non-secret config)
VITE_API_URL=https://api.example.com

# .env.local (gitignored — personal overrides, still never real secrets
# if this app has no backend of its own bundled alongside it)
VITE_API_URL=http://192.168.1.42:8080

Code Examples

The API's base URL is not sensitive — anyone using devtools' network tab can already see exactly which URL the app calls. This is exactly the kind of value environment variables are for: per-environment configuration, not secrecy.

const API_URL = import.meta.env.VITE_API_URL as string;

The VITE_ prefix guarantees this key is compiled into the public JavaScript bundle. Anyone can extract it from devtools and use it to send email as your account, exhaust your sending quota, or get your domain blacklisted for spam — all without ever touching your servers.

Common mistake: Any VITE_-prefixed variable holding a real API secret is, by construction, published to every visitor's browser.

The frontend now has zero knowledge of the SendGrid key. Your server validates and rate-limits the request before making the privileged call, and the real secret never leaves server-side process.env.

Stripe explicitly designs publishable keys (pk_...) to be safe in client-side code; they can only be used to tokenize card details and initiate a client-side flow, not to move money or read account data — authorization for the sensitive operations lives entirely with the paired secret key on your server.

// .env.production: VITE_API_URL=https://api.example.com

const API_URL = import.meta.env.VITE_API_URL as string;

async function fetchCourses() {
  const res = await fetch(`${API_URL}/courses`);
  return res.json();
}
// .env — VULNERABLE
// VITE_SENDGRID_API_KEY=SG.xxxxxxxxxxxxxxxxxxxxxxxx

// VULNERABLE component
async function sendWelcomeEmail(to: string) {
  await fetch("https://api.sendgrid.com/v3/mail/send", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${import.meta.env.VITE_SENDGRID_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ to /* ... */ }),
  });
}
// Frontend: no secret at all, just calls our own API
async function sendWelcomeEmail(to: string) {
  await fetch("/api/send-welcome-email", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ to }),
  });
}

// Backend (Node/Express) — SENDGRID_API_KEY is read from server
// process.env, never bundled or sent to any client.
app.post("/api/send-welcome-email", async (req, res) => {
  await sendgrid.send({
    to: req.body.to,
    apiKey: process.env.SENDGRID_API_KEY, // server-only, no VITE_ prefix
  });
  res.sendStatus(204);
});
// .env.production
// VITE_STRIPE_PUBLISHABLE_KEY=pk_live_51Abc...   (safe: designed to be public)

import { loadStripe } from "@stripe/stripe-js";

const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY);
// The matching sk_live_... SECRET key stays server-side only, used to
// actually create charges/payment intents — never exposed to the client.

Where a value ends up, based on its prefix and where it's read

The VITE_ prefix is a routing decision (client bundle vs. not), never a security boundary by itself.

.env / .env.production files
   |
   +-- KEY (no VITE_ prefix) --------> only readable by server-side
   |                                    process.env (Node backend), if
   |                                    a separate backend even reads it.
   |                                    NEVER reaches the browser bundle.
   |
   +-- VITE_KEY (VITE_ prefix) -------> statically inlined into the
                                         built JS bundle at build time
                                         --> shipped to EVERY browser
                                         --> fully public, always

Important Rules

Only VITE_-prefixed variables are exposed to client code via import.meta.env; everything else is invisible to the browser bundle by default.

Treat every VITE_-prefixed value as fully public the moment it's built — assume anyone can read it in devtools or the deployed JS files.

Never put a real secret (payment secret key, DB password, signing key, private third-party API key) behind a VITE_ prefix.

Commit .env / .env.[mode] files only when their contents are safe to be public; keep .local files gitignored for personal overrides.

Route privileged operations through your own backend, which holds the real secret in server-only process.env with no VITE_ prefix.

Changing a VITE_-prefixed value requires a rebuild and redeploy — it is not read at runtime from a live process environment in the browser.

Why this matters beyond the demo app

Automated secret-scanning bots continuously crawl public GitHub repositories and even deployed JavaScript bundles specifically looking for API-key-shaped strings; a VITE_-prefixed secret accidentally committed or simply built into a public deployment is often discovered and abused within minutes to hours, not after some manual audit. This is why the fix belongs in architecture (never issue the secret to the frontend at all) rather than in process (being careful not to commit it) — process discipline eventually fails, but a secret that was never given to the client can't be leaked from the client.

VITE_STRIPE_SECRET_KEY=sk_live_... in.env.production Keep sk_live_... in the backend's server-only environment; expose only VITE_STRIPE_PUBLISHABLE_KEY=pk_live_... to the client.

The VITE_ prefix guarantees inclusion in the public bundle; a secret key with that prefix is published to every visitor and can be used to move money or read account data if misused this way (Stripe secret keys specifically must never be client-exposed).

Assuming an unprefixed API_KEY in .env is automatically safe because Vite won't expose it via import.meta.env

Treat it as safe only if no other build step, script, or accidental console.log(process.env) ever surfaces it — and still keep real secrets out of any file a frontend build process reads at all.

The VITE_ prefix convention protects against one specific exposure mechanism (import.meta.env replacement); it is not a guarantee against every way a value could end up in shipped code.

Committing .env.local to version control 'just this once' to share a value with a teammate

Share non-secret values via.env.[mode] (committed) or your team's secret manager; keep.local files gitignored always..local files exist specifically for uncommitted personal/machine-specific overrides; committing one breaks that convention and risks the file later being used to smuggle in a secret without review.

Why does Vite only expose variables prefixed with VITE_ to client-side code via import.meta.env? A. It's a performance optimization to reduce bundle size B. It's a deliberate safety default so ordinary environment variables (which might include secrets) aren't accidentally shipped to every browser C. Vite cannot parse variable names longer than a certain length without the prefix D. The prefix is required by the JavaScript language specification

B. It's a deliberate safety default so ordinary environment variables (which might include secrets) aren't accidentally shipped to every browser — Requiring an explicit VITE_ prefix means only variables a developer deliberately opts in for client exposure end up in the bundle, reducing the chance of an ordinary backend-style secret being exposed by accident.

What happens when a VITE_-prefixed environment variable's value changes after the app has already been built and deployed? A. The running app picks up the new value automatically on next page load B. Nothing changes until the app is rebuilt and redeployed, because the value was statically inlined into the JS bundle at build time C. The change takes effect immediately for all users but not for the developer's own browser D. Vite polls the .env file at runtime and hot-reloads production apps automatically

B. Nothing changes until the app is rebuilt and redeployed, because the value was statically inlined into the JS bundle at build time — import.meta.env.VITE_* values are replaced with literal values during the build step; the deployed JavaScript has no live connection back to environment variables, so a new value requires a fresh build and deploy.

Why is a Stripe publishable key (pk_...) safe to expose via a VITE_ variable while the Stripe secret key (sk_...) is not? A. Publishable keys are encrypted automatically by Stripe's SDK B. Publishable keys are explicitly designed by Stripe for public client-side use and can only initiate limited client-side flows, while the secret key authorizes privileged server-side operations like creating charges C. There is no real difference; both are equally safe to expose D. Publishable keys expire after 24 hours automatically

B. Publishable keys are explicitly designed by Stripe for public client-side use and can only initiate limited client-side flows, while the secret key authorizes privileged server-side operations like creating charges — Stripe's key pair is designed with this exact split in mind: the publishable key is meant to be public and can only tokenize/initiate, while the secret key — which must never leave the server — is required for actually moving money or reading sensitive account data.

A developer adds SENDGRID_API_KEY=SG.xyz (no VITE_ prefix) to .env.production. Is this key exposed to the browser bundle? A. Yes, all variables in .env.production are always exposed regardless of prefix B. No — without the VITE_ prefix, Vite does not expose it via import.meta.env, though it should still not be committed if it's a real secret and no backend reads it from this file C. Only in development mode, not in production builds D. Only if the variable name is in all uppercase letters

B. No — without the VITE_ prefix, Vite does not expose it via import.meta.env, though it should still not be committed if it's a real secret and no backend reads it from this file — Vite only inlines VITE_-prefixed keys into import.meta.env for client code; an unprefixed key is invisible to the browser bundle by that mechanism, though good hygiene still keeps real secrets out of any file a frontend build process might read.

Your team wants the React app to call a weather API that charges per request and requires an API key. Design the environment-variable and backend architecture so the key is never exposed to the browser, and note which Vite env file(s) you'd use for what.

Store WEATHER_API_KEY (no VITE_ prefix) in the backend's server-only environment. Add a backend endpoint /api/weather?city=... that calls the paid weather API server-side using that key and returns just the needed data. The frontend only knows VITE_API_URL (e.g. http://localhost:8080 in .env.development, https://api.example.com in .env.production) and calls ${API_URL}/api/weather?city=... — it never sees WEATHER_API_KEY at any point.

Think about where the key is read (server process.env, no VITE_ prefix), what the frontend calls instead (your own backend endpoint), and how VITE_API_URL might differ between .env.development and .env.production for reaching that backend.