Environment Variables and Build Modes in Vite

This guide isolates the lifecycle of environment variables, mode resolution, and the secure injection boundary in Vite. For how the build pipeline resolves configuration layers before any substitution runs, start with the Vite Configuration & Ecosystem overview, then return here for the env-specific rules. The scope is strictly compile-time substitution, deterministic mode routing, the VITE_ prefix gate, and the .env file precedence chain across Vite 5.x/6.x. HMR internals and SSR hydration live in adjacent guides and are only referenced where env behavior depends on them.

Vite env file precedence and prefix gating Four dotenv files merge by precedence into a resolved set; only VITE_-prefixed keys pass the prefix gate into import.meta.env, the rest stay server-only. Precedence: highest wins on key conflict .env.[mode].local .env.[mode] .env.local .env top overrides bottom Resolved set prefix gate envPrefix = VITE_ import.meta.env VITE_* inlined into client bundle server-only loadEnv() / process.env never shipped Mode resolution: vite --mode <m> sets [mode]; dev defaults to development, build to production. import.meta.env.MODE = <m>; DEV/PROD/SSR are booleans set from the active command. Substitution is static: Vite replaces import.meta.env.VITE_X with a string literal at transform time. Static replacement runs before tree-shaking, so dead env branches are eliminated from the bundle.
Figure: dotenv precedence merges into one resolved set; the prefix gate decides what reaches import.meta.env.

Prerequisites

This guide assumes Vite 5.x or 6.x (npm create vite@latest), Node 18+ (20+ recommended), and a project using import.meta.env rather than process.env. The vite/client types ship with the package. Verify your toolchain before debugging precedence:

# Confirm versions before reasoning about precedence behavior
node -v            # v20.x
npx vite --version # vite/5.4.x or 6.x

Mode and env behavior changed subtly between major versions: Vite 6 tightened SSR env handling and made import.meta.env.SSR reliable in more contexts. Pin the version in package.json so precedence is reproducible across CI runners.

Core mechanics: the env pipeline

Vite does not read environment variables at runtime in the browser. It performs static replacement during the transform phase using esbuild in dev and Rollup in production. Access to import.meta.env is gated behind a mandatory prefix so server-only secrets cannot leak into client bundles by accident.

Static replacement and the compilation boundary

When Vite encounters import.meta.env.VITE_API_URL, it substitutes a raw string literal at transform time. Because this happens before tree-shaking, branches keyed on an env value become constant-foldable and unreachable code is stripped.

// vite.config.ts — Vite 5.x / 6.x
import { defineConfig } from 'vite'

export default defineConfig({
  // Default prefix is 'VITE_'. Extend only with prefixes you intend to ship.
  envPrefix: ['VITE_', 'PUBLIC_'],
})

Anything not matching envPrefix is excluded from import.meta.env in client code. That exclusion is the entire security model: there is no encryption, only a gate. Auditing the precedence and prefix rules across many .env files is the subject of the dedicated Managing multiple .env files across Vite environments guide.

Type-safe access

Augment ImportMetaEnv so the compiler tracks the same keys you ship:

// src/vite-env.d.ts — Vite 5.x / 6.x
/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_API_URL: string
  readonly VITE_FEATURE_FLAG: 'on' | 'off'
}

interface ImportMeta {
  readonly env: ImportMetaEnv
}

Without this reference, tsc --noEmit throws TS2339: Property 'VITE_API_URL' does not exist. The types are advisory only — they do not enforce that the variable was present at build time, which is the most common source of undefined in production, covered in Fixing import.meta.env undefined in production builds.

Build modes vs NODE_ENV

Vite decouples workflow state from Node’s process.env.NODE_ENV. The --mode flag drives which .env.[mode] file loads and what import.meta.env.MODE reports. It does not change NODE_ENV on its own — vite build sets NODE_ENV=production regardless of --mode, so --mode staging still produces a production-optimized bundle.

Mode resolution chain

  1. CLI flag: vite --mode staging or vite build --mode staging.
  2. Default mode: development for vite/vite dev, production for vite build/vite preview.
  3. .env files load for the resolved mode following the precedence chain (see below).
  4. import.meta.env.MODE is set to the resolved mode string; DEV, PROD, and SSR are derived booleans.
// package.json — mode-to-stage mapping
{
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "build:staging": "vite build --mode staging",
    "preview:staging": "vite preview --mode staging"
  }
}

Conditional configuration lets a single config file branch on the active mode:

// vite.config.ts — Vite 5.x / 6.x
import { defineConfig } from 'vite'
import devOnlyPlugin from './plugins/dev-only'
import stagingMock from './plugins/staging-mock'

export default defineConfig(({ mode, command }) => ({
  plugins: [
    mode === 'development' ? devOnlyPlugin() : null,
    mode === 'staging' ? stagingMock() : null,
  ].filter(Boolean),
  // command is 'serve' or 'build' — orthogonal to mode
  build: { sourcemap: command === 'build' && mode !== 'production' },
}))

.env file precedence

Vite resolves these files for a given mode, highest priority first. A key set in a higher file wins:

.env.[mode].local   # highest — git-ignored, machine-specific overrides
.env.[mode]         # committed, mode-specific values
.env.local          # git-ignored, loaded in every mode except 'test'
.env                # committed defaults, loaded in every mode

.local files are git-ignored by the Vite scaffold and should hold anything machine-specific. The .env.local file is skipped when mode === 'test' so test runs do not pick up a developer’s local overrides.

Programmatic loading with loadEnv

Config code and CI scripts that need env values before Vite finishes its own resolution use loadEnv. It returns a plain object scoped to the mode and is the correct way to read non-VITE_ values inside vite.config.ts.

// vite.config.ts — Vite 5.x / 6.x
import { defineConfig, loadEnv } from 'vite'

export default defineConfig(({ mode }) => {
  // Third arg '' disables the VITE_ filter so you can read server-only keys here.
  const env = loadEnv(mode, process.cwd(), '')

  return {
    define: {
      // Inject a derived constant; JSON.stringify is mandatory or the value
      // is spliced in as a bare identifier and Rollup throws.
      __BUILD_ID__: JSON.stringify(env.BUILD_ID ?? 'local'),
    },
    server: {
      proxy: { '/api': env.API_TARGET ?? 'http://localhost:3000' },
    },
  }
})

Reading process.env directly inside config works for variables injected by the shell or CI, but it bypasses the .env precedence chain entirely. Use loadEnv when you want the same merged result Vite itself computes. The configResolved hook is the right place to read finalized env for plugins, as detailed in Advanced Vite Plugin Configuration.

Step-by-step: a verified multi-mode setup

  1. Create .env with safe defaults: VITE_API_URL=http://localhost:3000.
  2. Create .env.staging with VITE_API_URL=https://staging.example.com.
  3. Add VITE_API_URL to src/vite-env.d.ts so the compiler tracks it.
  4. Read it in app code: const url = import.meta.env.VITE_API_URL.
  5. Build for staging: vite build --mode staging.
  6. Verify the literal landed in the bundle:
# Confirm the staging URL was inlined and the env key name was not shipped
grep -ro 'https://staging.example.com' dist/assets/*.js | head -n1
grep -rc 'import.meta.env' dist/assets/*.js   # expect 0 — all references inlined

A non-zero second count means a reference survived replacement — usually a dynamic access like import.meta.env[key], which Vite cannot statically resolve.

Debugging and failure modes

import.meta.env.VITE_X is undefined in production

Either the variable lacked the VITE_ prefix, the .env file was absent at build time (common in CI where .env is git-ignored), or the value was injected only into the runtime shell rather than the build step. Run vite build --debug and grep the output. The full diagnosis lives in Fixing import.meta.env undefined in production builds.

Wrong mode in CI

vite build defaults to production. If a pipeline omits --mode staging, it silently loads .env.production instead. Assert the mode at build start with a guard that logs import.meta.env.MODE, or pass --mode explicitly in every CI job.

Edited .env not picked up

Vite reads .env files at server start. Editing one during a running dev session has no effect until you restart; there is no hot reload for env files. Restart the server or run with --force to also clear the dep cache.

TS2339 on import.meta.env

The vite/client reference is missing. Add /// <reference types="vite/client" /> to a .d.ts in the project and ensure it is included by tsconfig.json.

Compatibility matrix

Vite Node Env behavior of note
5.0–5.4 18 / 20 loadEnv, envPrefix, full precedence chain stable
6.0–6.x 18 / 20 / 22 Improved SSR env handling; import.meta.env.SSR reliable in more contexts
any .env.local skipped when mode === 'test'; vite build forces NODE_ENV=production

In-Depth Guides