Fixing import.meta.env Undefined in Production Builds

import.meta.env.VITE_API_URL works on your machine and reads undefined in production. The value was never inlined at build time, so the browser sees a missing property. This guide walks the five causes in order of frequency and verifies each fix by grepping dist; for the precedence and prefix model behind the behavior, start with Environment Variables and Build Modes in Vite.

Decision path for import.meta.env undefined in production A build-time variable flows through prefix, presence, scope, and replacement checks; failing any check yields undefined in the production bundle. Build-time variable to inlined literal — every gate must pass Has VITE_ prefix? Present at build time? Client-side access? Static reference? Inlined literal defined in bundle Any gate fails to undefined in production no error thrown — the property is simply absent 1. Prefix: only envPrefix keys (default VITE_) reach import.meta.env in client code. 2. Presence: the value must exist where vite build runs — CI shell or env file, not the runtime host. 3. Scope: server-only vars and SSR loadEnv values are not injected into client modules. 4. Static: import.meta.env[key] is dynamic; Vite only replaces literal property access.
Figure: four gates between a build-time variable and an inlined literal — failing any one yields undefined in production.

Problem scope

import.meta.env.VITE_X is undefined in a vite build output, often only in CI or on the deployed host, while vite dev works. The value was never substituted into the bundle, and accessing a missing property returns undefined without an error.

Prerequisites and reproducible setup

# Vite 5.x / 6.x, Node 20+
npm create vite@latest meta-demo -- --template vanilla-ts
cd meta-demo && npm install

# A correctly-prefixed var and a deliberately wrong one
printf 'VITE_API_URL=https://api.example.com\nAPI_TOKEN=secret-not-prefixed\n' > .env
// src/main.ts — reproduces both the working and broken cases
console.log('url:', import.meta.env.VITE_API_URL) // inlined literal
console.log('tok:', import.meta.env.API_TOKEN)    // undefined — no VITE_ prefix
npx vite build
grep -ro 'https://api.example.com' dist/assets/*.js | head -n1  # match: VITE_API_URL inlined
grep -ro 'API_TOKEN' dist/assets/*.js                            # no match: never shipped

Diagnosis workflow

1. Missing VITE_ prefix (most common)

Only keys matching envPrefix (default VITE_) are exposed on import.meta.env in client code. API_TOKEN above is loaded into the Node process but deliberately excluded from the client bundle. Rename it to VITE_API_TOKEN only if it is genuinely safe to ship, since the prefix is the security boundary, not encryption.

2. Not present at build time (the CI trap)

Vite inlines values during vite build. If .env is git-ignored (it usually is) and CI never sets the variable in the build step’s shell, the key is absent when substitution runs and bakes in as undefined. Setting it on the runtime host (the container that serves the static files) is too late — the bundle is already built. Inject it into the build job:

# .github/workflows/deploy.yml — value must exist for the build STEP
- name: Build
  run: npm run build
  env:
    VITE_API_URL: ${{ secrets.VITE_API_URL }}   # present when vite build runs

Confirm presence before building:

# Fail fast in CI if a required var is missing at build time
: "${VITE_API_URL:?VITE_API_URL must be set before vite build}"
npm run build

3. Reading a server-only variable on the client

Variables intended for the server (database URLs, API secrets) must never carry VITE_. If a component reads import.meta.env.DATABASE_URL, it is undefined on the client by design. Keep server values prefix-free and read them through loadEnv or process.env in server code only.

4. define replacement timing and dynamic access

Vite replaces import.meta.env.VITE_X only when the property is accessed statically. A computed access defeats the replacement:

// BROKEN: dynamic key — Vite cannot statically replace this, stays a runtime lookup
const key = 'VITE_API_URL'
const url = import.meta.env[key]            // undefined in production

// FIXED: static property access is replaced with the literal at build time
const url2 = import.meta.env.VITE_API_URL

The same applies to custom define entries: the right-hand value must be JSON.stringify-wrapped, or Rollup splices in a bare identifier and either throws or produces undefined.

5. SSR builds and loadEnv

In an SSR build the server bundle does not automatically receive import.meta.env.VITE_X the way the client does for every code path. Load values explicitly in the server entry or config with loadEnv, and read process-level vars through process.env on the server:

// vite.config.ts — make non-VITE_ values available to config/SSR code
import { defineConfig, loadEnv } from 'vite'

export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd(), '') // '' returns all keys, incl. non-VITE_
  return {
    define: {
      // Expose a server value to SSR code deliberately and safely
      'import.meta.env.SSR_REGION': JSON.stringify(env.SSR_REGION ?? 'us-east-1'),
    },
  }
})

SSR env loading depends on the same precedence chain as the client; see Configuring Vite SSR with Express and Node.js for where the server entry reads these values.

The fix, end to end

# 1. Prefix the variable for client exposure
printf 'VITE_API_URL=https://api.example.com\n' > .env.production

# 2. Ensure it is present in the build environment (locally or in CI)
export VITE_API_URL=https://api.example.com

# 3. Build for production and verify the literal landed
npm run build
grep -ro 'https://api.example.com' dist/assets/*.js | head -n1   # match = fixed
// src/main.ts — static access only
const url = import.meta.env.VITE_API_URL
if (!url) throw new Error('VITE_API_URL missing — was it set at build time?')

Verification

# The expected value is present as a literal
grep -ro 'https://api.example.com' dist/assets/*.js | head -n1   # expect a match

# No raw import.meta.env survived static replacement
grep -rc 'import.meta.env' dist/assets/*.js                      # expect 0

# No server-only names leaked into the client bundle
grep -ro 'API_TOKEN\|DATABASE_URL' dist/assets/*.js              # expect no output

A surviving import.meta.env reference points at a dynamic access (cause 4). A leaked server name means a value was VITE_-prefixed or define-injected when it should not have been.

Gotchas and edge cases

Works in dev, undefined in prod

vite dev exposes the live import.meta.env object, so a dynamic access (import.meta.env[key]) appears to work. The production build statically replaces only literal accesses, so the dynamic form silently breaks. Always test the actual vite build output, not just the dev server.

Empty string is not undefined

A var defined as VITE_API_URL= inlines as "", which is falsy but not undefined. Guards that check === undefined pass while if (!url) fails. Decide which sentinel your code expects and assert it explicitly.

dist served from a CDN reflects the old build

If you set the variable on the host after deploying, the already-built bundle still carries the old (or undefined) literal. Rebuild and redeploy — there is no runtime re-read for import.meta.env in client code.

Booleans and numbers are strings

import.meta.env.VITE_FLAG is always a string. VITE_FLAG=false inlines as "false", which is truthy. Compare against the string or parse it; do not rely on JavaScript truthiness.