Skip to content
intermediate Phase 15 · Production Builds and Capstone Project

Environment Variables in Vite

Expose mode-specific configuration through prefixed import.meta.env values.

35m
0 problems
Topic Progress 0%

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:

  1. Rotate it at the provider first (Stripe dashboard → roll the key). Code removal does not un-send bytes already cached in browsers and CDNs.
  2. Purge CDN caches for the affected assets, then ship a build without the variable.
  3. Scrub git history if the secret ever sat in a committed .env, and add .env.local and friends to .gitignore.
  4. 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?

Question 1 options

2. A variable is defined in `.env`, `.env.local`, and `.env.staging.local`. Which value wins during `vite build --mode staging`?

Question 2 options

3. For a staging build (`vite build --mode staging`), which statements about import.meta.env are true?

Question 3 options

4. You find `sk_live_…` in dist/assets after a release. What is the correct response?

Question 4 options

Flashcards

Question

Which environment variables reach client code, and what mechanism exposes them?

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?

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?

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?

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?

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.

Continue Learning

Before You Start

Related Reading