5 min read
Env Vars & Build Modes
Only `VITE_`-prefixed variables reach the client, values are baked in at build time, and mode is not the same axis as environment.
Principles
Only `VITE_`-prefixed variables reach the client
Vite explicitly filters `import.meta.env` to variables prefixed `VITE_`. Anything else stays server/build-only by design, not by convention you have to remember.
`import.meta.env` values are replaced at build time, not read at runtime
A `VITE_API_URL` becomes a literal string baked into the bundle. There is no 'change the env var and the deployed build picks it up' the way a server process has.
Mode and environment are different axes
`--mode staging` controls which `.env.[mode]` file loads, not which target you're deploying to. Conflating the two produces a 'staging build' that's secretly using development API URLs because nobody created `.env.staging`.
`.env.local` is for machine-specific secrets, and must never reach version control
It's gitignored by Vite's scaffolding by default. Treat a missing `.gitignore` entry for it as a security bug, not a lint nit.
A variable that must differ between requests can't be an env var
Env vars are compiled in once. Anything that needs to vary at runtime — per-tenant config, feature flags toggled live — needs a runtime source, not `import.meta.env`.
Patterns
What goes wrong, what to do instead, and why the difference matters.
Leaking a secret through an unprefixed name
Avoid
// .env
// STRIPE_SECRET_KEY=sk_live_...
// The instinct to "just add the env var" without the prefix is the
// bug — Vite won't expose it, but a secret key has no business in
// a client bundle regardless of prefix.
console.log(import.meta.env.STRIPE_SECRET_KEY); // undefined in the bundleThe unprefixed variable silently isn't exposed, which looks like a bug until you realize the fix is 'don't expose it at all.'
Prefer
// .env — client-safe only
// VITE_STRIPE_PUBLISHABLE_KEY=pk_live_...
// Secret keys stay server-side, read via process.env in a server
// context, never through import.meta.env.The prefix convention forces a deliberate choice about what's client-visible; secret keys never get a `VITE_` name in the first place.
Expecting a runtime read that's actually build-time
Avoid
// Built once for staging, deployed, then someone edits the staging
// .env file on the server hoping the running app picks it up.
const apiUrl = import.meta.env.VITE_API_URL;
// It doesn't — the value was baked into the bundle at build time.The env var is treated like a server process's environment, but it was already replaced with a literal string during `vite build`.
Prefer
// A value that must be changeable post-deploy is fetched at
// runtime, not baked in as a build-time constant.
const config = await fetch("/runtime-config.json").then((r) => r.json());
const apiUrl = config.apiUrl;Runtime-changeable config is fetched, not compiled in — the build becomes environment-agnostic and gets promoted between stages unchanged.
Mode and environment conflation
Avoid
# Only .env and .env.production exist.
# `vite build --mode staging` silently falls back to
# .env's dev values because .env.staging was never created.The staging build has no idea it's staging — it inherits whatever .env happens to define.
Prefer
# .env shared defaults
# .env.development
# .env.staging
# .env.production
vite build --mode staging # loads .env + .env.stagingEvery mode that's actually built has its own file, so the loaded variables match the environment being targeted.
Review checklist
What I look for when reviewing a pull request that touches this area.
- Every client-exposed variable is deliberately prefixed `VITE_`; nothing sensitive is one typo away from being exposed.
- No secret key or credential exists in any `VITE_`-prefixed variable, regardless of whether it's currently read anywhere.
- Config that must change post-deploy is fetched at runtime, not read from `import.meta.env`.
- Every mode that's actually built (`--mode X`) has a corresponding `.env.X` file, not an implicit fallback.
- `.env.local` and any file holding real secrets is confirmed gitignored, not assumed.
- CI/deploy pipelines set the env vars each build actually needs before invoking `vite build`.
Take the skill
Everything above, generated into a skill file your agent can load. Copy it, or download it and commit it to your repo.
.claude/skills/vite-env-and-modes/SKILL.mdDrop into .claude/skills/ or .cursor/skills/ — the frontmatter drives when the skill loads.
---
name: vite-env-and-modes
description: >-
Vite environment variables and build modes: the VITE_ prefix boundary,
build-time vs runtime config, mode-specific .env files, and keeping
secrets out of the client bundle. Use when adding env vars or configuring
build modes.
argument-hint: "[env var or mode]"
license: MIT
metadata:
author: John Felix Lim
version: "1.0.0"
---
# Env Vars & Build Modes
Apply when adding a new environment variable or build mode. First decide whether the value is client-safe — if it isn't, it never gets a VITE_ prefix, full stop.
## Rules
1. **Only `VITE_`-prefixed variables reach the client** — Vite explicitly filters `import.meta.env` to variables prefixed `VITE_`. Anything else stays server/build-only by design, not by convention you have to remember.
2. **`import.meta.env` values are replaced at build time, not read at runtime** — A `VITE_API_URL` becomes a literal string baked into the bundle. There is no 'change the env var and the deployed build picks it up' the way a server process has.
3. **Mode and environment are different axes** — `--mode staging` controls which `.env.[mode]` file loads, not which target you're deploying to. Conflating the two produces a 'staging build' that's secretly using development API URLs because nobody created `.env.staging`.
4. **`.env.local` is for machine-specific secrets, and must never reach version control** — It's gitignored by Vite's scaffolding by default. Treat a missing `.gitignore` entry for it as a security bug, not a lint nit.
5. **A variable that must differ between requests can't be an env var** — Env vars are compiled in once. Anything that needs to vary at runtime — per-tenant config, feature flags toggled live — needs a runtime source, not `import.meta.env`.
## Patterns
### Leaking a secret through an unprefixed name
**Avoid** — The unprefixed variable silently isn't exposed, which looks like a bug until you realize the fix is 'don't expose it at all.'
```ts
// .env
// STRIPE_SECRET_KEY=sk_live_...
// The instinct to "just add the env var" without the prefix is the
// bug — Vite won't expose it, but a secret key has no business in
// a client bundle regardless of prefix.
console.log(import.meta.env.STRIPE_SECRET_KEY); // undefined in the bundle
```
**Prefer** — The prefix convention forces a deliberate choice about what's client-visible; secret keys never get a `VITE_` name in the first place.
```ts
// .env — client-safe only
// VITE_STRIPE_PUBLISHABLE_KEY=pk_live_...
// Secret keys stay server-side, read via process.env in a server
// context, never through import.meta.env.
```
### Expecting a runtime read that's actually build-time
**Avoid** — The env var is treated like a server process's environment, but it was already replaced with a literal string during `vite build`.
```ts
// Built once for staging, deployed, then someone edits the staging
// .env file on the server hoping the running app picks it up.
const apiUrl = import.meta.env.VITE_API_URL;
// It doesn't — the value was baked into the bundle at build time.
```
**Prefer** — Runtime-changeable config is fetched, not compiled in — the build becomes environment-agnostic and gets promoted between stages unchanged.
```ts
// A value that must be changeable post-deploy is fetched at
// runtime, not baked in as a build-time constant.
const config = await fetch("/runtime-config.json").then((r) => r.json());
const apiUrl = config.apiUrl;
```
### Mode and environment conflation
**Avoid** — The staging build has no idea it's staging — it inherits whatever .env happens to define.
```bash
# Only .env and .env.production exist.
# `vite build --mode staging` silently falls back to
# .env's dev values because .env.staging was never created.
```
**Prefer** — Every mode that's actually built has its own file, so the loaded variables match the environment being targeted.
```bash
# .env shared defaults
# .env.development
# .env.staging
# .env.production
vite build --mode staging # loads .env + .env.staging
```
## Review checklist
- [ ] Every client-exposed variable is deliberately prefixed `VITE_`; nothing sensitive is one typo away from being exposed.
- [ ] No secret key or credential exists in any `VITE_`-prefixed variable, regardless of whether it's currently read anywhere.
- [ ] Config that must change post-deploy is fetched at runtime, not read from `import.meta.env`.
- [ ] Every mode that's actually built (`--mode X`) has a corresponding `.env.X` file, not an implicit fallback.
- [ ] `.env.local` and any file holding real secrets is confirmed gitignored, not assumed.
- [ ] CI/deploy pipelines set the env vars each build actually needs before invoking `vite build`.
---
From the John Felix Lim Frontend Engineering Playbook — https://github.com/JohnFelixLim