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.

The reason this bug is so common is that import.meta.env behaves like two completely different things depending on whether you are in the dev server or a build. In vite dev it is a genuine live JavaScript object: the dev server injects a module that populates it at runtime, and any property access — literal or computed — resolves against that object the instant your code runs. In vite build there is no such object. Vite’s define plugin performs a textual find-and-replace over your source during the transform phase, swapping each literal import.meta.env.VITE_X occurrence for the stringified value it held when the build started. What ships to the browser is a constant; the import.meta.env object never exists at runtime for anything that was inlined. A property you did not inline is therefore not “empty” — it is simply not there, and reading it yields undefined with no thrown error to point you at the line.

That silence is what makes the failure expensive. Nothing crashes at build time, nothing warns in the console, and the symptom often surfaces only after deploy, as a fetch to undefined/api/users or a feature flag that reads the wrong way. Because the substitution happens at build time and nowhere else, the fix always lives in the build pipeline — the shell that runs vite build, the .env file it can see, the prefix on the key, and the shape of the access expression — never in the runtime host, the CDN, or the container’s environment. The rest of this guide treats each of those pipeline stages as a gate the value must pass, and every fix ends with a grep on dist so you are reasoning about the shipped bytes rather than about what you hoped the build did.

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.

The “works locally, breaks in CI” pattern deserves a specific note, because it fools people into blaming Vite rather than their pipeline. Locally your shell has the variable exported, or a .env file sits in the project root, so the value is present when you run vite build by hand. In CI the checkout is clean, .env is git-ignored and never restored, and the secret lives in the platform’s secret store rather than in the build step’s environment. Same command, same Vite version, different shell — and the different shell is the entire cause. When triaging, the first question is never “which Vite option do I need”; it is “was the value in process.env at the moment vite build executed”, and that is answerable by printing the key names (never the values) in the build log before the build runs.

Why dev works and production does not In dev the server exposes a live import.meta.env object so any access resolves; in a production build only literal accesses are statically replaced, so anything not inlined reads undefined with no error. vite dev live import.meta.env object every access resolves at runtime — even dynamic keys appear to work vite build only literals inlined anything not inlined → undefined no error is thrown
Figure: the dev/prod gap is the whole bug — a live object in dev, a frozen literal in the build.

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

This repro deliberately pairs a correctly-prefixed key with an unprefixed one so the two outcomes are visible in the same dist grep. The point of grepping the emitted assets rather than logging in the browser is that the grep is deterministic and runs in CI without a headless browser: a literal that was inlined is physically present as a string in the JavaScript file, so a zero-match grep is proof the substitution did not happen, independent of whatever the running page reports. Keep this repro around as a control — when a real project misbehaves, reproduce the same two-key split in a scratch app first to confirm your Vite version still inlines correctly before you go hunting through your own config.

Prefixed versus unprefixed key in the repro VITE_API_URL passes the prefix gate and is inlined as a literal in dist, while API_TOKEN lacks the prefix and never reaches the client bundle. VITE_API_URL API_TOKEN prefix gate inlined in dist never shipped
Figure: same gate, two outcomes — the grep on dist makes the split observable.

Diagnosis workflow

Walk the five causes in frequency order — the first two account for the large majority of reports, so check them before reaching for the SSR or define explanations.

Five causes ranked by frequency Missing prefix is most common, then absent at build time in CI, then reading a server-only variable on the client, then dynamic or mistimed define access, and least often SSR builds not loading env explicitly. 1 · missing VITE_ prefixmost common 2 · absent at build time (CI trap) 3 · server-only var read on client 4 · dynamic access / define timing 5 · SSR build, no loadEnv
Figure: the bar length is frequency — start at the top and stop at the first cause that matches your grep.

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.

Mechanically, when Vite loads env files it partitions the keys into two sets. Everything is available to vite.config.ts and to server code through loadEnv, but only the prefix-matching subset is placed on the import.meta.env object that the define plugin substitutes into client modules. The prefix is not a naming convention Vite tolerates — it is the enforced gate that decides what is allowed to cross into shippable code, which is precisely why treating a rename as a security decision matters. Anything inlined into dist/assets/*.js is world-readable the moment the page loads; there is no server round-trip and no obfuscation. If you ever find yourself prefixing a database password to make an error go away, you have not fixed a bug, you have shipped a credential.

The confirmation for this cause is the fastest of all five: grep the file you loaded from and check the key spelling.

# Confirm the key actually carries the prefix Vite expects (Vite 5.x / 6.x)
grep -n '^VITE_' .env .env.production 2>/dev/null   # the client-visible keys
grep -n 'API_TOKEN' .env                            # unprefixed → never reaches the client

If you deliberately use a non-default prefix, remember it is set in config and must match everywhere. A common self-inflicted version of this bug is setting envPrefix: 'APP_' in vite.config.ts and then continuing to write VITE_-named keys, which now fail the gate for the opposite reason:

// vite.config.ts — a custom prefix silently orphans VITE_-named keys (Vite 5.x / 6.x)
import { defineConfig } from 'vite'

export default defineConfig({
  envPrefix: 'APP_', // now only APP_* keys are exposed; VITE_* keys are ignored on the client
})

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. The mental model that prevents this class of bug is a timeline: env resolution happens once, synchronously, while vite build runs, and the output is frozen. Any environment change that lands after that instant — a Kubernetes secret mounted into the serving pod, a value exported in the container’s entrypoint, a dashboard variable set on the hosting platform — reaches a runtime that no longer reads import.meta.env, because the reads were compiled away. For a static SPA there is no server process to consult the environment; the environment was consulted at build time and its answers were literal-pasted into the assets. 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

The :? guard is worth adopting as policy rather than as a one-off. Its value is that it converts a silent, deploy-time-only failure into a loud, build-time one: the build exits non-zero with a named message, the CI job goes red, and nothing broken ever reaches the CDN. That trade is almost always correct — a failed build is cheap and recoverable, a shipped undefined API base URL is a production incident. If you have several required keys, assert them in one place so a fresh environment fails once with a complete list rather than one key at a time:

# preflight.sh — assert every required client var exists before building (bash 4+)
required=(VITE_API_URL VITE_SENTRY_DSN VITE_STRIPE_KEY)
missing=()
for k in "${required[@]}"; do [ -n "${!k:-}" ] || missing+=("$k"); done
if [ ${#missing[@]} -gt 0 ]; then
  echo "Missing at build time: ${missing[*]}" >&2   # names only, never values
  exit 1
fi
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.

This cause is the inverse of cause 1, and the undefined here is a feature, not a defect. The typical path into it is a developer copying an env name from a server-side file into a React component to “wire up the same value”, hitting undefined, and reaching for the prefix to unblock themselves. That instinct is exactly backwards: the client genuinely should not know your database URL, so the correct fix is to move the code, not the prefix. The value belongs behind an API route, an SSR loader, or a build-time step that derives a safe public value from the secret one. If the client truly needs a piece of information that today lives only in a server secret, mint a separate, deliberately-public key for it (VITE_PUBLIC_API_URL) so the decision to expose it is explicit and reviewable in a diff, rather than an accident of prefixing. Confirm the boundary held by grepping the shipped bundle for the server name — it must return nothing, which is one of the assertions in the verification section below.

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 reason the dynamic form cannot be replaced is that define operates as a syntactic substitution before any code runs. It scans the AST for the exact member-expression shape import.meta.env.VITE_API_URL and swaps that node for a string literal. import.meta.env[key] is a computed member expression whose property is only known once key has a value at runtime — information the build does not have and will not compute. There is no partial evaluation and no constant-folding of the surrounding variable; the expression is left untouched, and at runtime it indexes into an import.meta.env object that, in the build, contains only the handful of keys Vite chose to define, so the lookup returns undefined. The same limitation applies to destructuring (const { VITE_API_URL } = import.meta.env) and to aliasing the object (const e = import.meta.env; e.VITE_API_URL) — both hide the literal member access from the scanner, so neither is replaced. Write the full import.meta.env.VITE_X at every use site, even though it is verbose; that verbosity is what makes the value inlinable.

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. The wrapping requirement trips people because define substitutes the raw text of the value, not a runtime value. Given define: { __API__: 'https://api.example.com' }, Vite pastes the unquoted characters into your code, producing const x = https://api.example.com, a syntax error. Wrapping in JSON.stringify turns it into the source text "https://api.example.com", a valid string literal. The rule generalizes: whatever you want to appear in the emitted source, hand define the JSON.stringify of it.

// vite.config.ts — define wants source text, so stringify the value (Vite 5.x / 6.x)
import { defineConfig } from 'vite'

export default defineConfig({
  define: {
    __API__: JSON.stringify('https://api.example.com'), // → "https://api.example.com"
    __BUILD_TS__: JSON.stringify(Date.now()),           // a number literal is fine too
    // __API__: 'https://api.example.com',              // WRONG: pastes bare tokens, syntax error
  },
})

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. The asymmetry exists because the client build’s whole job is to produce a self-contained bundle with no environment to read, so inlining is mandatory; the server build runs in Node, where a real process.env exists at runtime, so Vite leans on that instead of inlining everything. The failure mode appears when shared code — a data-loading module imported by both the browser entry and the server entry — reads import.meta.env.VITE_X and works on the client (inlined) but reads undefined on the server, because the server transform did not inline it and the value was never placed on process.env. 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

Three-step fix from prefix to verified literal Prefix the variable for client exposure, ensure it is present in the build environment, then build for production and grep dist to confirm the literal landed. 1 · prefix VITE_client exposure 2 · present at buildCI build step env 3 · build + grep distliteral confirmed
Figure: the fix is ordered — exposure, then presence, then proof.
# 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

Three grep assertions that prove the fix The expected literal must appear in dist, no raw import.meta.env references may remain, and no server-only names may have leaked into the client bundle. grep literal in dist expect: 1 match grep -c import.meta.env expect: 0 grep API_TOKEN | DATABASE_URL expect: no output
Figure: three assertions — one presence, two absences — that together confirm the build is correct.
# 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

Four env edge cases Dynamic access works in dev but not prod; an empty string inlines as an empty string not undefined; a CDN keeps serving the old build until you rebuild; and every env value is a string so false inlines as truthy. works in dev, undefined in prod dynamic access resolves live in dev but is never statically replaced in the build. empty string ≠ undefined VITE_X= inlines as "" — falsy but passes an === undefined guard. CDN reflects the old build setting the var on the host after deploy changes nothing — rebuild and redeploy. every value is a string VITE_FLAG=false inlines as "false", which is truthy — parse, don't trust it.
Figure: two silent-breakage cases on top, two value-shape traps below.

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. Confirm by running vite preview against a fresh build and reproducing the exact user flow — preview serves the real dist, so a value that resolves under dev but is undefined under preview pins the cause to dynamic access without a deploy.

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. This most often happens when a CI secret exists but is set to an empty value, or when a .env line has a trailing = with nothing after it. Confirm by grepping the build for the assignment: grep -o 'VITE_API_URL[^,}]*' dist/assets/*.js shows "" for the empty case versus a real literal, distinguishing “present but blank” from “never inlined” — two bugs with the same visible symptom but different fixes.

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. A subtler version of this is a cached asset: even after a correct redeploy, an aggressively cached index-<hash>.js or a CDN edge that has not revalidated can serve the stale build to some users. Confirm the fix actually reached the browser by reading the hashed filename in the network tab or grepping the deployed asset directly (curl -s <asset-url> | grep -o 'https://api.example.com') rather than trusting that “the deploy succeeded” means every edge is current.

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. The safe pattern is an explicit comparison at the read site — const enabled = import.meta.env.VITE_FLAG === 'true' — which collapses every non-"true" value, including "false", "", and an absent key, to false. Confirm the inlined shape with the same grep as the empty-string case; seeing "false" (with quotes) in dist is the tell that a truthiness check would have been wrong. The two built-in booleans, import.meta.env.DEV and import.meta.env.PROD, are the exception — Vite inlines those as real boolean literals, so they are the only env values you can trust to JavaScript truthiness.