Environment Variables and Build Modes in Vite
This article isolates the lifecycle of environment variables, mode resolution, and secure injection boundaries within Vite. It deliberately excludes broader configuration syntax, HMR internals, and SSR hydration mechanics, which are documented in adjacent clusters. The focus is strictly on compile-time substitution, deterministic mode routing, and measurable performance impacts across Vite 5.x/6.x pipelines.
1. Core Architecture: Vite’s Environment Variable Pipeline
Vite does not inject environment variables at runtime. Instead, it performs static AST replacement during the compilation phase using esbuild (dev) and Rollup (prod). Access to import.meta.env is gated behind a mandatory prefix to prevent accidental leakage of server-only secrets into client bundles.
Static Replacement & Compilation Boundary
When Vite encounters import.meta.env.VITE_API_URL, it replaces the identifier with a raw string literal during the transform step. This substitution occurs before tree-shaking, enabling dead-code elimination for unused branches.
// vite.config.ts
import { defineConfig } from 'vite'
export default defineConfig({
envPrefix: ['VITE_', 'APP_'], // Extend default prefix safely
})
Performance Impact: esbuild handles define substitutions in <5ms for typical monorepos. Because variables are inlined at compile time, there is zero runtime overhead, and unused env branches are stripped during Rollup’s tree-shaking phase, typically reducing production bundle size by 2–4% depending on conditional feature flags.
TypeScript Augmentation
To maintain type safety across the compilation boundary, augment ImportMetaEnv globally:
// src/env.d.ts
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string
readonly VITE_FEATURE_FLAG: 'on' | 'off'
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
Debugging Workflow
- Run
console.log(import.meta.env)in bothviteandvite buildoutputs. Dev mode exposes the full object; prod mode outputs only inlined literals. - Inspect
dist/assets/*.jswithcat dist/assets/index-*.js | grep -o 'VITE_[A-Z_]*'to confirm static replacement. Absence ofimport.meta.envstrings verifies successful esbuild/Rollup define injection.
For foundational context on how Vite resolves configuration layers before env substitution begins, consult the Vite Configuration & Ecosystem pillar.
2. Build Modes vs. NODE_ENV: Decoupling Workflow States
Vite explicitly decouples workflow states from Node’s process.env.NODE_ENV. The --mode CLI flag drives configuration resolution, .env file loading, and import.meta.env.MODE/import.meta.env.PROD flags.
Mode Resolution Chain
Vite evaluates modes in this strict order:
- CLI flag:
vite --mode staging vite.config.{mode}.ts(if present).env.{mode}→.env.{mode}.local→.env→.env.local- Fallback:
development(dev) orproduction(build)
// package.json
{
"scripts": {
"dev:preview": "vite --mode preview",
"build:staging": "vite build --mode staging"
}
}
Conditional configuration enables mode-aware plugin routing:
// vite.config.ts
import { defineConfig } from 'vite'
export default defineConfig(({ mode }) => ({
plugins: [
mode === 'development' ? devOnlyPlugin() : null,
mode === 'staging' ? stagingMockServer() : null,
].filter(Boolean),
}))
Performance Impact: Mode-specific configs can reduce dev server cold start by 15–30% by disabling heavy analysis plugins (e.g., linting, type-checking) in development or preview modes. This directly shrinks the initial module graph and reduces HMR payload size, as detailed in Optimizing Vite Dev Server and HMR.
Debugging Workflow
- Execute
vite --debug envto trace the exact mode resolution and.envfile loading sequence. - Verify compiled output contains
import.meta.env.MODE === "staging"andimport.meta.env.PROD === false(ortruefor production builds).
3. Programmatic Env Loading and Plugin Integration
Plugin authors and CI pipelines often require programmatic access to environment variables before Vite’s internal resolution completes. The loadEnv() API provides deterministic, prefix-agnostic loading for custom build scripts and virtual module generation.
Safe Injection Patterns
// vite.config.ts
import { defineConfig, loadEnv } from 'vite'
import path from 'node:path'
export default defineConfig(({ mode }) => {
// Load all env vars (empty string prefix disables VITE_ filter)
const env = loadEnv(mode, process.cwd(), '')
return {
envDir: path.resolve(__dirname, '../shared-config'),
define: {
__CUSTOM_FLAG__: JSON.stringify(env.CUSTOM_FLAG || 'false'),
__BUILD_TIMESTAMP__: JSON.stringify(new Date().toISOString()),
},
}
})
Why loadEnv() over process.env? Vite’s plugin system runs in a shared Node context. Mutating process.env directly causes cross-plugin pollution and non-deterministic builds across parallel workers. loadEnv() returns a plain object scoped to the current mode, ensuring reproducible CI caching.
For advanced patterns on intercepting the configResolved hook to modify env resolution before the plugin graph initializes, see Advanced Vite Plugin Configuration.
Debugging Workflow
- Validate the
loadEnv()return object against expected keys before passing todefine. Missing keys will stringify asundefinedand trigger Rollup warnings. - Monitor Rollup output for
WARNING: "process.env" is not definedorundefined variableduring tree-shaking phases. Replace with explicitdefinemappings.
4. Security Boundaries and Multi-Environment File Management
Vite performs static replacement, not encryption. Any variable prefixed with VITE_ (or custom envPrefix) is inlined into the client bundle. Strict file precedence and CI injection strategies are mandatory to prevent secret exposure.
Precedence Chain & CI Injection
Vite resolves files in this exact priority (highest to lowest):
.env.local > .env.{mode}.local > .env.{mode} > .env
# CI/CD Pipeline Injection (GitHub Actions / GitLab CI)
# Never commit secrets to .env files
VITE_SECRET_KEY=$SECRET_KEY vite build --mode production
Security Impact: Accidentally committing VITE_-prefixed secrets increases bundle size and exposes credentials in source maps. Auditing production bundles should be automated.
Debugging Workflow
- Run
grep -r 'VITE_' dist/post-build to audit for hardcoded secrets. Any match indicates a prefix violation or CI misconfiguration. - Use
vite-plugin-inspectto verify that server-only environment variables are stripped from client modules and never reach the virtual module graph.
For granular routing rules, precedence overrides, and monorepo .env sharing strategies, refer to Managing multiple .env files across Vite environments.
5. Troubleshooting Matrix: Env Leaks, Mode Conflicts, and HMR Sync
| Symptom | Root Cause | Diagnostic Command | Resolution |
|---|---|---|---|
import.meta.env.VITE_X is undefined in prod |
Missing VITE_ prefix or .env not loaded |
vite --debug env |
Add prefix, verify envDir path, ensure file exists |
Mode mismatch in CI (development instead of production) |
NODE_ENV override or missing --mode flag |
vite build --debug |
Explicitly pass --mode production; avoid NODE_ENV=production alone |
HMR env desync after .env edit |
Dev server caches env at startup | vite --force or restart server |
Vite does not hot-reload .env files; restart required |
TypeScript TS2339 on import.meta.env |
Missing env.d.ts or vite/client reference |
tsc --noEmit |
Add /// <reference types="vite/client" /> |
Advanced Diagnostics
- esbuild Metafile Analysis: Generate
--metafile=meta.jsonduringvite build. Cross-referenceimport.meta.envbranches to verify dead-code elimination. Unused env conditionals typically account for3–8%of unoptimized bundle weight. - Framework Adapter Parity: Cross-reference
process.env.NODE_ENVvsimport.meta.env.MODEin React/Vue/Svelte adapters. Mismatches cause hydration warnings. Align both viadefine: { 'process.env.NODE_ENV': JSON.stringify(mode) }. - Isolated CI Testing: Validate fallback chains in Docker containers (
docker run --env-file .env.production node:20-alpine vite build) to replicate exact CI states without host pollution.
Production Implementation Workflows
Multi-Environment Setup
- Define
envDirat the monorepo root for shared configuration. - Map
--modeflags to CI stages (ci:lint,ci:build,ci:deploy). - Enforce
VITE_prefix compliance via ESLint:eslint-plugin-viteor customimport/no-restricted-pathsrules.
Plugin Authoring
- Invoke
loadEnv(mode, process.cwd(), '')inside theconfigResolvedhook. - Pass sanitized values to
defineusingJSON.stringify(). - Never mutate
process.envdirectly; use virtual modules (virtual:my-plugin/env) for runtime-safe access.
Framework Maintenance
- Override
import.meta.envtypes in framework-specifictsconfig.jsonto prevent drift. - Ensure SSR hydration respects build-time boundaries: client bundles receive inlined literals; server contexts read from
loadEnv()or nativeprocess.env. - Validate mode switching in dev/prod parity tests by asserting
import.meta.env.MODEmatches the active pipeline stage.