Why process.env.API_URL Is undefined in the Browser — and What Vite Exposes Instead
The browser console says ReferenceError: process is not defined on the first line of your API client. Your teammate swears the same code worked at their last job on webpack — because webpack's DefinePlugin convention auto-substituted process.env.*, and Vite deliberately does not. Vite has one mechanism for client-side configuration, and it works by substitution, not by a runtime object.
import.meta.env.VITE_* references are replaced with literal values during the build transform. There is no import.meta object in the output at all:
// your source
const apiBase = import.meta.env.VITE_API_URL
// after `vite build` with VITE_API_URL=https://api.acme.test
const apiBase = "https://api.acme.test"
That substitution model explains every rule that follows. It is also why the exposure is unconditional: the compiler cannot know which branches your code takes, so everything prefixed VITE_ lands verbatim in the JavaScript every visitor downloads. The prefix is not a naming style — it is the opt-in flag for publication:
# .env
VITE_API_URL=https://api.acme.test # public by design — fine
VITE_MAPS_KEY=pk.live_abc123 # publishable key — fine
API_PROXY_TARGET=http://localhost:4000 # never reaches import.meta.env
The failure mode of the last line is quiet: import.meta.env.API_PROXY_TARGET is undefined at runtime, with no build error and no warning. Non-prefixed variables still exist — for Node-side build configuration, read them with loadEnv inside vite.config.js:
import { defineConfig, loadEnv } from 'vite'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '') // '' = no prefix filter, Node-side only
return {
server: { proxy: { '/api': env.API_PROXY_TARGET } }, // dev-server concern
define: { __BUILD_ID__: JSON.stringify(env.BUILD_ID) } // careful: also inlined into the client
}
})
Watch the last line: anything you put through define is inlined into the browser bundle by the same substitution logic — a different front door to the same leak.
File loading is layered, and the order decides which value wins:
| Priority (highest first) | Source |
|---|---|
| Real environment variables | shell/CI: VITE_API_URL=... vite build — overrides all files |
.env.[mode].local |
local per-mode overrides, gitignored |
.env.[mode] |
committed per-mode values (VITE_API_URL in .env.staging) |
.env.local |
local overrides for every mode, gitignored |
.env |
committed defaults for everyone |
That order is why CI can override any committed default without touching a file, and why .env.local belongs in .gitignore on pain of teammates overwriting each other's API endpoints. Dotenv's expansion syntax works too — VITE_API_BASE=${VITE_API_URL}/v2 — which keeps derived values in one place.
Two practical habits come out of the substitution model. First, when you suspect exposure, console.log(import.meta.env) in dev lists exactly the VITE_-prefixed values the client can see — compare it against your .env file and the gap between "what I configured" and "what the browser gets" is one screenshot wide. (In dev the server injects a live env object; production builds replace each reference statically, which is why a missing prefix shows up as undefined in both.) Second, the dev-server proxy is the one place Node-side configuration legitimately shapes the browser's world: the proxy target (http://localhost:4000) comes from non-prefixed env read inside vite.config.js, and the browser only ever sees same-origin /api requests — no CORS, no secret.
If you came from Nuxt for Full-Stack Vue Apps, note the contrast: Nuxt separates runtime runtimeConfig (server-only, adjustable after build) from publicRuntimeConfig, while a plain Vite SPA gets exactly one channel and it is public. That constraint is what the rest of this topic builds on — and the head-management work in Titles, Meta, and SPA SEO will consume some of these values for per-environment metadata.
One Codebase, Three Backends: Modes, .env.staging, and MODE vs PROD
Your team needs the same codebase talking to three different backends: localhost:4000 on laptops, staging-api.acme.test on the QA environment, and api.acme.test in production. Hardcoding means three sets of edits and one memorable incident. In Vite, the environment is a build mode, and modes are where per-environment configuration lives.
vite # mode: development
vite build # mode: production
vite build --mode staging # mode: staging — your own modes are allowed
Each mode selects its dotenv files: --mode staging loads .env → .env.local → .env.staging → .env.staging.local, last one wins. So the config itself is committed next to the code it configures:
# .env.staging
VITE_API_URL=https://staging-api.acme.test
VITE_SENTRY_DSN=https://staging-dsn.ingest.sentry.io/42
# .env.production
VITE_API_URL=https://api.acme.test
VITE_SENTRY_DSN=https://sentry.io/77
# CI — one line per environment, same dist/ output directory
vite build --mode staging && deploy-to staging-bucket
Coverage matters more than naming: every environment that differs in any URL gets its own file, committed beside the code, so a reviewer sees the configuration change in the same pull request as the feature it configures — no hidden coupling between a deploy and an undocumented variable somewhere in a pipeline UI.
The client code never branches on which file it came from — it reads one name and the mode decides the value:
const api = axios.create({ baseURL: import.meta.env.VITE_API_URL })
if (import.meta.env.MODE === 'staging') Sentry.setTag('env', 'staging')
import.meta.env gives you three related switches: MODE is the literal mode string; DEV and PROD are booleans derived from NODE_ENV (PROD is true for every vite build, regardless of mode — a staging build is PROD: true, MODE: 'staging'). That distinction trips people up: staging is a production-kind build (minified, no devtools, no hot reload) with staging values. Use MODE for configuration, PROD for "am I on a dev server" behavior checks. Resist setting NODE_ENV by hand in dotenv files — Vite derives it, and overriding it is documented as unsupported territory.
Because env is decided at build time, each environment ships its own artifact: one vite build --mode staging output cannot be promoted to production by copying files, because the staging API URL is baked into the literals. That is the correct default — auditable, no runtime surprise — and it makes the counter-design a deliberate trade: a single artifact for all environments needs runtime configuration, fetched after load:
// one artifact, environment decided at deploy time
const config = await fetch(`${import.meta.env.BASE_URL}config.json`).then(r => r.json())
// config.json is written by the deploy script per environment; app must boot after it lands
The trade is a fetch before first request (and a wrong-environment flash if you get the ordering wrong) for a build pipeline that promotes the same bytes through environments. Interviewers like this pair precisely because neither answer is free — the question is who pays: the pipeline or the user.
Local verification of a mode build is quick: run vite preview --mode staging (the preview server applies the same dotenv layering) and confirm with one grep that the mode actually reached the output — grep -l 'staging-api' dist/assets/*.js names the file where the staging URL was substituted.
One more tool that falls out of the same mechanism: typed access. Vite ships vite/client types, and augmenting them turns typos into compile errors:
// env.d.ts
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string
readonly VITE_SENTRY_DSN?: string // optional in staging only
}
interface ImportMeta { readonly env: ImportMetaEnv }
Now import.meta.env.VITE_API_URL autocompletes and import.meta.env.VITE_APIL_URL fails the build instead of shipping undefined to production — which matters most in the one place you cannot afford silent configuration: the secrets question, next.
The Build Published Your Secret Key: What VITE_ Actually Means
A security review runs one command against your public site and finds your Stripe secret key:
$ grep -rlo "sk_live_" dist/assets
dist/assets/index-2b84d5e1.js
No exploit, no misconfigured server — the key was VITE_STRIPE_SECRET_KEY in .env, and the build dutifully substituted it into the JavaScript that every visitor downloads. If it carries the VITE_ prefix, it is public. The prefix is not encryption, obfuscation, or conditional access; it is a compile-time flag whose entire effect is "paste this value into the browser bundle." Minification mangles identifiers, not string literals — your secret sits in the file as readable text at a predictable offset.
Classify your variables honestly before naming them:
Safe to expose (VITE_) |
Never exposed |
|---|---|
| API base URLs | Database passwords, connection strings |
Publishable keys (Stripe pk_live_, map keys, Firebase public config) |
Stripe secret keys (sk_live_) |
| Sentry DSNs (designed for client use) | JWT signing secrets, private tokens |
| Feature-flag keys with public scope | Anything a backend can hold instead |
Repository hygiene keeps the audit quiet: commit .env holding team defaults with public values only, gitignore .env.local and every file holding a real credential, and never commit a secret "temporarily for this branch" — git history is append-mostly, and a key that was rotated in place but still lives in an old commit resurfaces in the next scan. A useful interview-room habit: state the classification rule before naming examples — the prefix decides exposure, the destination decides whether exposure is acceptable.
The design rule behind the table: if an operation needs a privileged secret, it needs a server. The frontend calls your own endpoint — /api/checkout — and the server-side code (or a serverless function) holds the real key. This is not a workaround for Vite; it is how payment flows are supposed to be built, because no amount of build tooling can keep a secret inside a file you must send to the attacker's machine.
If a leak ships anyway, treat the key as compromised from the moment it existed — not from the moment you noticed:
- Rotate it at the provider first (Stripe dashboard → roll the key). Code removal does not un-send bytes already cached in browsers and CDNs.
- Purge CDN caches for the affected assets, then ship a build without the variable.
- Scrub git history if the secret ever sat in a committed
.env, and add.env.localand friends to.gitignore. - Make recurrence impossible to merge:
# CI: history scan + artifact sweep
gitleaks detect --source . -v
grep -rE "sk_live_|AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{36}" dist/ && exit 1
The artifact sweep is the one that catches this exact class of bug: it scans what you are about to publish, which is the only place the guarantee matters.
Two lingering questions worth being able to answer in an interview. First, "can I hide it with a shorter name or an env indirection?" — no; a build-time substitution cannot protect a value the build must read. Second, "how do teams ship one build to many environments if env is baked in?" — they don't; each environment gets its own vite build --mode, or the app switches to deploy-time runtime config (config.json / an injected window.__APP_CONFIG__), trading a boot-time fetch for artifact reuse. Both routes keep the same invariant: the browser may only ever know what the browser is allowed to know. That discipline also applies to what you tell search engines about each page — the subject of Titles, Meta, and SPA SEO.
Quiz
1. `.env` sets `API_SECRET=topsecret` (no VITE_ prefix) and your code reads `import.meta.env.API_SECRET`. What happens?
2. A variable is defined in `.env`, `.env.local`, and `.env.staging.local`. Which value wins during `vite build --mode staging`?
3. For a staging build (`vite build --mode staging`), which statements about import.meta.env are true?
4. You find `sk_live_…` in dist/assets after a release. What is the correct response?
Flashcards
Question
Which environment variables reach client code, and what mechanism exposes them?
Click to reveal answer
Answer
Only those prefixed VITE_, and by static replacement: `import.meta.env.VITE_X` is compiled into a literal in the emitted JavaScript. There is no runtime env object in the browser — which is why process.env throws and why anything VITE_-prefixed is guaranteed public.
Question
What is the dotenv loading priority in Vite?
Click to reveal answer
Answer
.env → .env.local → .env.[mode] → .env.[mode].local, later files override earlier ones, and real process/CI environment variables override all files. Keep .env (committed defaults) in git; gitignore .env.local and other local overrides.
Question
How do MODE, DEV, and PROD relate, and how do you build for staging?
Click to reveal answer
Answer
`vite build --mode staging` sets MODE='staging' and loads .env.staging*, but PROD stays true (builds are always production-kind: minified, no HMR). Branch configuration on MODE; use DEV/PROD only for dev-server detection. NODE_ENV should not be set by hand.
Question
The frontend needs a privileged API key — where does it go?
Click to reveal answer
Answer
On the server. The client calls your own endpoint (/api/checkout), and server-side code or a serverless function holds the secret. No build mechanism can keep a secret inside a file that must be downloaded by whoever you are hiding it from.
Question
One artifact for several environments: what is the trade-off?
Click to reveal answer
Answer
Env values are baked at build time, so the default is one build per environment (auditable, no runtime surprise). Single-artifact deployments switch to runtime config — a config.json or injected window.__APP_CONFIG__ fetched at boot — trading a pre-boot fetch (and flash-of-wrong-env risk) for artifact promotion.
Revision Notes
Key Takeaways
- 1. Vite substitutes import.meta.env.VITE_* literals during the build; process.env does not exist in the browser, and the VITE_ prefix is an opt-in to publication, not a naming convention
- 2. Non-prefixed variables are not dead — they are for the Node side of the build (vite.config, dev proxy), read with loadEnv; but anything routed through define is inlined client-side too
- 3. Environments are build modes: `--mode staging` selects .env.staging files and sets MODE, while the build stays production-kind (PROD true) — minified, no dev tools
- 4. Each mode produces its own artifact because values are baked in; the single-artifact alternative is runtime config fetched at boot, with its own flash-of-wrong-env cost
- 5. A VITE_-prefixed secret is leaked at build, not at deploy — containment starts with rotation at the provider, and prevention is a backend endpoint plus a CI artifact sweep
Interview Tips
- • "Why is process.env undefined in my Vue app?" — Vite has no Node runtime in the browser and no DefinePlugin-style auto-substitution; the supported path is import.meta.env.VITE_*, replaced statically at build time — say 'replaced' to show you know it is not a runtime lookup
- • "How do you configure staging vs production?" — one codebase, per-mode dotenv files, `vite build --mode staging`; distinguish MODE from PROD (staging builds are still PROD) to show you have actually debugged a staging-only surprise
- • "A secret ended up in the client bundle — what now?" — rotate first (bytes are already cached everywhere), then move the call behind a backend, then add a dist-grep CI gate; mention that minification does not protect string literals
- • "Can one build serve all environments?" — not with build-time env; either build per environment or fetch runtime config at boot — be ready to name the trade (pipeline complexity vs boot-time fetch and flash risk)
Cheat Sheet
Environment Variables in Vite
Mechanism: import.meta.env.VITE_* is replaced with a literal at build time — no object, no process.env in the browser (that is the ReferenceError).
# .env
VITE_API_URL=https://api.acme.test # public — lands verbatim in JS
API_PROXY=http://localhost:4000 # Node-side only; client read = undefined
// vite.config.js — non-prefixed values, build-time only
const env = loadEnv(mode, process.cwd(), '')
Priority (highest wins): process/CI env → .env.[mode].local → .env.[mode] → .env.local → .env.
Modes: vite → development · vite build → production · vite build --mode staging → staging (loads .env.staging*). MODE = mode string; PROD/DEV = NODE_ENV-derived (staging builds still PROD: true).
Types: env.d.ts with /// <reference types="vite/client" /> + interface ImportMetaEnv { readonly VITE_API_URL: string } → autocompletion, typo = build error.
Secrets rule: VITE_ = publish to the internet. Secret found in dist → rotate first, purge CDN, fix the design (backend endpoint holds the key), add gitleaks detect + grep -rE "sk_live_|AKIA…" dist/ && exit 1 to CI. Env indirection/mangling hides nothing from a client that downloads the bundle.