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

  1. Run console.log(import.meta.env) in both vite and vite build outputs. Dev mode exposes the full object; prod mode outputs only inlined literals.
  2. Inspect dist/assets/*.js with cat dist/assets/index-*.js | grep -o 'VITE_[A-Z_]*' to confirm static replacement. Absence of import.meta.env strings 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:

  1. CLI flag: vite --mode staging
  2. vite.config.{mode}.ts (if present)
  3. .env.{mode}.env.{mode}.local.env.env.local
  4. Fallback: development (dev) or production (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

  1. Execute vite --debug env to trace the exact mode resolution and .env file loading sequence.
  2. Verify compiled output contains import.meta.env.MODE === "staging" and import.meta.env.PROD === false (or true for 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

  1. Validate the loadEnv() return object against expected keys before passing to define. Missing keys will stringify as undefined and trigger Rollup warnings.
  2. Monitor Rollup output for WARNING: "process.env" is not defined or undefined variable during tree-shaking phases. Replace with explicit define mappings.

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

  1. Run grep -r 'VITE_' dist/ post-build to audit for hardcoded secrets. Any match indicates a prefix violation or CI misconfiguration.
  2. Use vite-plugin-inspect to 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.json during vite build. Cross-reference import.meta.env branches to verify dead-code elimination. Unused env conditionals typically account for 3–8% of unoptimized bundle weight.
  • Framework Adapter Parity: Cross-reference process.env.NODE_ENV vs import.meta.env.MODE in React/Vue/Svelte adapters. Mismatches cause hydration warnings. Align both via define: { '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

  1. Define envDir at the monorepo root for shared configuration.
  2. Map --mode flags to CI stages (ci:lint, ci:build, ci:deploy).
  3. Enforce VITE_ prefix compliance via ESLint: eslint-plugin-vite or custom import/no-restricted-paths rules.

Plugin Authoring

  1. Invoke loadEnv(mode, process.cwd(), '') inside the configResolved hook.
  2. Pass sanitized values to define using JSON.stringify().
  3. Never mutate process.env directly; use virtual modules (virtual:my-plugin/env) for runtime-safe access.

Framework Maintenance

  1. Override import.meta.env types in framework-specific tsconfig.json to prevent drift.
  2. Ensure SSR hydration respects build-time boundaries: client bundles receive inlined literals; server contexts read from loadEnv() or native process.env.
  3. Validate mode switching in dev/prod parity tests by asserting import.meta.env.MODE matches the active pipeline stage.

In-Depth Guides