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.

The problem this system exists to solve is that a browser bundle has no environment. There is no process.env in the page, no shell that hands the code a database URL at load time, and no server to consult. Everything the client will ever know about its environment has to be decided before the bundle is written to disk. Vite’s answer is to treat environment variables as build-time inputs: it reads .env files while the config is resolving, freezes the selected values into the JavaScript as string literals, and ships those literals. That single design choice — substitution rather than lookup — explains almost every surprising behavior on this page, from why editing a .env file does nothing until you restart, to why a variable that works locally is undefined in the deployed bundle, to why a secret that never appears in your source can still end up in a public artifact.

What breaks without a disciplined mental model is usually one of two things. Either a value that should have been baked in is missing, and the app silently reads undefined — a blank API host, a feature flag that reads falsy, a Sentry DSN that never initializes — or a value that should never have shipped is inlined into a file that anyone can download. Both failures are quiet: nothing throws at build time, and the bundle is produced successfully. The prefix gate and the precedence chain are the two mechanisms that decide which of these happens, and the rest of this guide is a precise account of how each one works, where it fits in the pipeline, and how to confirm it did what you expected.

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.

The reason the version matters more than it appears is that env resolution is not part of any web standard — it is Vite-specific behavior implemented in the config loader, and it can and does move between minor releases. A floating ^5.0.0 range means one developer resolves against 5.0.11 and CI resolves against 5.4.x, and if the two disagree about, say, whether .env.local is consulted in a given mode, you get a bug that reproduces on exactly one machine. Lockfiles close most of that gap, but only a pinned or narrowly ranged vite dependency makes the precedence contract itself explicit. If you rely on import.meta.env.SSR to branch server-only code out of the client bundle, treat Vite 6 as a hard floor rather than a nice-to-have, because on 5.x that flag is not reliably set in every transform context and a mis-set boolean here means server code leaks into the browser.

Toolchain assumptions for env behaviour The guide assumes Vite 5 or 6, Node 18 or newer, access through import.meta.env rather than process.env, and the vite/client types that ship with the package. Assumed before reasoning about precedence Vite 5.x / 6.xpinned in package.json Node 18+ (20+)reproducible CI import.meta.envnot process.env vite/client typesships with package
Figure: the four assumptions that make precedence deterministic across machines and 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. The single most important mental model is when substitution happens: it is compile-time, not runtime, so the value is frozen into the bundle and any branch keyed on it is decided before the code ever ships.

The pipeline runs in a fixed order, and knowing that order is what lets you predict the outcome instead of guessing. First, Vite resolves the mode from the CLI flag or the command default. Second, it walks the four candidate .env files for that mode and merges them into a single flat map, with higher-precedence files overwriting lower ones key by key. Third, it applies the envPrefix filter, discarding every key that does not begin with an allowed prefix from the set that will be exposed to client code. Fourth, it constructs the import.meta.env object, adding the built-in keys MODE, DEV, PROD, BASE_URL, and SSR alongside the surviving prefixed values. Only then does transformation begin, and every literal access to import.meta.env.SOMETHING in your source is rewritten to the corresponding value as a JSON-encoded literal. Nothing about this touches the running browser — by the time the page loads, import.meta.env is a plain object that was serialized into the bundle, not a live view of any environment.

That the two production tools differ — esbuild for the dev-server transform and Rollup for the production build — is worth internalizing because it is a common source of “works in dev, wrong in build” reports. Both honor the same resolved env map, so the values agree, but they run at different times relative to your feedback loop. In dev the substitution happens per-module as files are requested, which is why a stale value persists until restart. In build it happens once, up front, and the result is subject to Rollup’s tree-shaking and minification, which is where env-gated dead code actually gets deleted.

Compile-time substitution ahead of tree-shaking At transform time Vite replaces import.meta.env.VITE_X with a string literal; because that runs before tree-shaking, branches keyed on the value constant-fold and unreachable code is dropped from the bundle. sourceimport.meta.env.VITE_X transform→ "https://…" literal tree-shakedead branch dropped bundlefrozen Substitution precedes optimization — the value is a literal, not a lookup There is no runtime env in the browser — only what was frozen at build time.
Figure: the literal is spliced in before tree-shaking, so env-gated branches are resolved at build time.

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.

The replacement is textual and syntactic, not semantic. Vite matches the exact member-expression shape import.meta.env.KEY — a dot access with a static identifier — and swaps it for a JSON-stringified literal. It does not evaluate the surrounding expression, and it cannot follow a value through a variable. This is why import.meta.env.VITE_API_URL is inlined but const e = import.meta.env; e.VITE_API_URL frequently is not: the second form aliases the whole env object, and by the time you index into e, there is no static member expression left for the replacer to recognize. The same limitation defeats computed access. import.meta.env['VITE_' + name] is a dynamic key that cannot be resolved at transform time, so it survives into the bundle as a real property lookup on whatever object import.meta.env becomes, which in most client builds is a small frozen object holding only the keys that happened to match the prefix. Prefer literal dotted access everywhere you want a value baked in, and treat any surviving import.meta.env reference in dist as a signal that a substitution was missed.

Once the value is a literal, constant folding does the rest. A block guarded by if (import.meta.env.VITE_DEBUG === 'true') becomes if ('false' === 'true') after substitution, which Rollup folds to if (false) and then deletes entirely, taking any imports used only inside that block with it. This is the mechanism that lets you keep verbose logging, mock transports, or a development-only inspector in the source tree and still ship a build that contains none of it. The consequence of getting it wrong is the inverse: if the guard reads a value that Vite could not inline, the branch stays live and the “development-only” code — and everything it imports — ships to production.

// featureGate.ts — Vite 5.x / 6.x
// Inlinable: literal dotted access folds to a constant, dead branch is dropped.
export function transport() {
  if (import.meta.env.VITE_USE_MOCKS === 'true') {
    // Entire import graph below is tree-shaken out of a production build
    // where VITE_USE_MOCKS is unset, because the condition folds to false.
    return import('./mockTransport')
  }
  return import('./httpTransport')
}

// Anti-pattern: aliasing defeats the static replacer.
// `bag` is the whole env object, so `bag.VITE_USE_MOCKS` is a runtime lookup,
// the branch never folds, and both transports ship.
const bag = import.meta.env
export const brokenGate = bag.VITE_USE_MOCKS === 'true'
// 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.

Treat the prefix as a publication boundary, not an obfuscation layer. Any value that clears the gate is inlined verbatim into a file served to the public; the network tab and view-source both reveal it, and no amount of minification hides it. The correct division is that anything safe to print on a billboard — a public API base URL, a Stripe publishable key, a build identifier, a feature flag — can wear the VITE_ prefix, while anything whose disclosure has consequences — a database URL, a private API token, a signing secret, a Stripe secret key — must never carry it and should be consumed only server-side through process.env or loadEnv. Widening envPrefix is therefore a decision with a blast radius: adding PUBLIC_ as above means a stray PUBLIC_ADMIN_TOKEN in someone’s .env.local would be shipped without warning. Keep the allowed set as narrow as the project can tolerate, and never add an empty-string prefix (envPrefix: ''), which disables the gate entirely and exposes every loaded variable — Vite refuses this outright for exactly that reason.

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.

The gap between what the type says and what the build guarantees is worth stating plainly, because the declaration invites a false sense of safety. Declaring VITE_API_URL: string tells the compiler the property exists and is a string; it does not run at build time, does not read your .env files, and cannot know whether the variable was actually set for the mode you are building. If the file is missing, the property is undefined at runtime while its static type is still string, so nothing warns you until the app dereferences a value it expected to be present. Two habits close this gap. First, avoid typing keys as non-optional when they might genuinely be absent — a value that only exists in staging should be readonly VITE_STAGING_ONLY?: string so the compiler forces you to handle the missing case. Second, validate presence at the edge of the build rather than trusting the declaration, which the CI integration section below turns into a concrete guard. Keep the interface in lockstep with the keys you actually ship: a declared key with no corresponding .env entry is a lie the type system will happily repeat.

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. Three axes move independently here, and conflating them is the root of most “why is my staging build minified like production” confusion.

The confusion is understandable because tools that came before Vite fused these axes. In a classic Webpack setup, NODE_ENV was the single knob: setting it to development chose unminified output, verbose React builds, and development-only warnings all at once, and there was no separate concept of a named mode. Vite deliberately split that knob into three because the questions it answers are genuinely different. What is Vite doing — serving or building — is the command. Which set of environment values applies — the account, the API host, the feature flags for this deployment — is the mode. How should the toolchain and third-party libraries behave — dev warnings versus production fast paths — is NODE_ENV. A staging deployment wants production optimization with staging values, which is only expressible if the mode and NODE_ENV are allowed to disagree. That is exactly the combination Vite produces by default, and the fact that it looks like a bug (“my staging bundle is minified”) is really the design working as intended. If you genuinely need an unoptimized staging build for debugging, you override NODE_ENV yourself in the environment before invoking vite build, not through --mode.

Three independent axes: command, mode, NODE_ENV The command axis is serve or build; the mode axis selects which dotenv file loads and sets import.meta.env.MODE; NODE_ENV is forced to production by vite build regardless of mode. command serve | build what Vite is doing (dev server vs bundle) mode (--mode) development | staging | … picks .env.[mode] sets import.meta.env.MODE NODE_ENV production (forced) vite build sets it — even with --mode staging
Figure: --mode staging changes which env loads, not whether the output is production-optimized.

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"
  }
}

The order in this chain is not arbitrary: the resolved mode string is the input to every step that follows, so an error at step one or two poisons everything downstream. If the flag is misspelled — --mode staginng — Vite does not error; it faithfully looks for .env.staginng, finds nothing, falls back to only the mode-agnostic .env and .env.local, and reports MODE === 'staginng'. The build succeeds with the wrong values, which is why asserting the mode near the top of the config or the CI job is worth the two lines it costs. Note also that vite preview defaults to production like build, not to development — previewing a staging bundle requires passing --mode staging to the preview command as well, or you will serve a production-configured artifact under the impression you are checking 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.

The merge is per-key, not per-file, and this is the detail people get wrong. Vite does not pick one winning file and use it wholesale; it layers all applicable files into a single map, and for each individual key the highest-precedence file that defines it wins. A VITE_API_URL set in .env.staging.local overrides the one in .env, but a VITE_SENTRY_DSN that appears only in .env is still present in the staging build because nothing above it redefines it. This is what makes the layered layout ergonomic: .env holds the full set of committed defaults, mode files override only the handful of keys that differ per environment, and .local files carry the one or two machine-specific overrides a given developer needs. The failure this design guards against is duplicating the entire variable set into every mode file, which drifts the instant someone adds a key to .env and forgets to copy it into .env.production — the layered merge means you never have to.

The test exception exists because test runs must be hermetic. If .env.local — which by convention holds a developer’s personal API tokens and pointed-at-my-laptop URLs — were loaded during mode === 'test', the same test suite would behave differently on every machine and in CI, which defeats the purpose of a test. Vite therefore skips .env.local (but not .env, and not .env.test / .env.test.local) whenever the mode is test, so a test-mode build sees committed defaults plus explicit test overrides and nothing personal. If you need a secret in a test, put it in .env.test.local, which is still consulted, rather than expecting .env.local to apply.

Resolving one key across four files For a staging build the same VITE_API_URL key is set in .env, .env.staging and .env.staging.local; the highest-precedence file, .env.staging.local, wins and its value is the one inlined. VITE_API_URL, --mode staging .env.staging.local → https://s-eu.internal (wins) .env.staging → https://staging.example.com .env.local → (unset for this key) .env → http://localhost:3000 inlined value https://s-eu.internal
Figure: the topmost file that sets the key wins; lower files supply only the keys the higher ones omit.

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.

There are three properties of loadEnv worth committing to memory because each maps to a real mistake. The first argument is the mode, and it must be the resolved mode — inside defineConfig(({ mode }) => …) you already have it, so pass it through rather than hardcoding a string. The second argument is the directory to search, almost always process.cwd(), though in a monorepo where env files live above the package you point it at the workspace root or set envDir so the resolution matches the running app. The third argument is the prefix filter, and this is the one that trips people up: passing '' disables the filter so loadEnv returns every key it finds, prefixed or not, which is precisely what you want inside config because config runs on the server and legitimately needs non-VITE_ values like API_TARGET or BUILD_ID. That third argument does not change what ships to the client — the client-side import.meta.env is still gated by envPrefix — it only changes what your config code can see. Confusing the two gates is how a define block accidentally inlines a server secret: loadEnv(mode, cwd, '') reads the secret into config, and if you then hand it to define, you have manually reintroduced the leak the prefix gate exists to prevent. Read broadly with loadEnv, but only pass values into define after deciding they are safe to publish.

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

Six steps from defaults to a verified inlined value Create default env, add a mode file, type the variable, read it in app code, build for the mode, then grep dist to verify the literal was inlined and no import.meta.env references survived. 1 .envdefaults 2 .env.stagingmode value 3 type itvite-env.d.ts 4 read itin app code 5 build--mode staging 6 verifygrep dist Step 6 is the only one that proves the previous five worked.
Figure: the flow ends in a grep, not a hope — the inlined literal is the acceptance test.
  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.

The reason the sixth step is not optional is that steps one through five all fail silently. A missing .env.staging, a variable that lacks the prefix, a typo’d key in the type declaration, an aliased access in app code — none of these produce an error at build time. The build completes, the artifact is written, and the failure only shows up when a human loads the deployed page and notices the API call went nowhere. Grepping dist converts that late, human-detected failure into an early, mechanical one: the literal is either in the bundle or it is not, and there is no interpretation to argue about. Wire the two greps into the build job as a hard gate — the first must find at least one match, the second must find zero — and a broken env configuration fails the pipeline instead of reaching users. Because the value is now a string literal in a minified file, you can also confirm the right value landed, not merely that some value did: grepping for the production host in a staging bundle is a fast way to catch a mode mix-up that the count checks alone would miss.

Debugging and failure modes

Four env failure modes by symptom and cause Undefined in production traces to a missing prefix or absent env file; the wrong mode in CI traces to an omitted --mode flag; edited env not picked up traces to no restart; and TS2339 traces to a missing vite/client reference. symptom root cause · fix VITE_X undefined in prod missing prefix / .env absent in CI → add both wrong .env loaded in CI --mode omitted → pass it in every job edited .env not applied env read at boot → restart the server TS2339 on import.meta.env no vite/client reference → add the triple-slash
Figure: every env failure is one of four; the fix is on the right of its symptom.

import.meta.env.VITE_X is undefined in production

Symptom. The value reads correctly in vite dev on your machine, but the deployed bundle behaves as if the variable were empty — a blank API host, an uninitialized SDK, a feature flag that evaluates falsy. Root cause. One of three things: the variable lacked the VITE_ prefix and was silently dropped by the gate; the .env file that defines it was absent at build time, which is the common case in CI because .env and .env.*.local are git-ignored and never checked out; or the value was exported into the runtime shell of the deploy host rather than the build host, so it existed when the container ran but not when Rollup inlined literals. Fix. Confirm the prefix, ensure the env values are present in the build environment (as repository or CI secrets injected before vite build, not after), and remember there is no runtime fallback to reach for. Confirm. Run vite build --debug and grep its output for the key, or grep dist/assets for the expected literal — a zero-match grep for the value plus a non-zero grep for import.meta.env.VITE_X means the substitution never had a value to make. The full diagnosis lives in Fixing import.meta.env undefined in production builds.

Wrong mode in CI

Symptom. A staging deployment points at production services, or vice versa, even though the correct values exist in .env.staging. Root cause. vite build defaults to production, so a pipeline that runs a bare vite build for its staging job silently loads .env.production and never touches .env.staging — the mode string was never set, so the mode-specific file was never a candidate. Fix. Pass --mode staging explicitly in every job that is not building production, and treat the default as production-only. Confirm. Add a guard that fails the build when the mode and the intended target disagree, so a missing flag stops the pipeline instead of shipping the wrong config.

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

export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd(), '')
  // Fail fast: a deploy target must be declared and must match the mode.
  const target = process.env.DEPLOY_TARGET
  if (target && target !== mode) {
    throw new Error(
      `Mode/target mismatch: --mode ${mode} but DEPLOY_TARGET=${target}. ` +
        `Pass --mode ${target} to load .env.${target}.`,
    )
  }
  // A required public key with no value is a build error, not a runtime undefined.
  if (!env.VITE_API_URL) {
    throw new Error(`VITE_API_URL is unset for mode ${mode}; check .env.${mode}.`)
  }
  return {}
})

Edited .env not picked up

Symptom. You change a value in a .env file while vite dev is running and the app keeps using the old value. Root cause. Vite reads and merges .env files once, at server start, and freezes the result for the session; there is no file watcher on env files and no hot reload for them, by design, because a live env change would require re-running every transform that inlined a literal. Fix. Restart the dev server. If the change also affects dependency pre-bundling — for example an env value that a plugin reads to decide how to optimize deps — restart with --force to clear the cached optimized dependencies as well. Confirm. After the restart, read import.meta.env.MODE and the changed key in the console or a temporary log; if the new value appears, the reload took.

TS2339 on import.meta.env

Symptom. tsc --noEmit reports TS2339: Property 'VITE_X' does not exist on type 'ImportMetaEnv', and your editor underlines the access, even though the code runs. Root cause. The vite/client triple-slash reference — which supplies the base ImportMetaEnv and ImportMeta types — is missing from the project’s compilation, or your augmentation of ImportMetaEnv is in a file tsconfig.json does not include. Fix. Add /// <reference types="vite/client" /> to a .d.ts file (conventionally src/vite-env.d.ts) alongside the ImportMetaEnv interface that declares your keys, and make sure the file falls inside the include globs. Confirm. Re-run tsc --noEmit; the error clears once the reference and the augmentation are both in the compilation. Remember this is a type error only — it never affects whether the value is inlined, which is governed entirely by the prefix and the .env files.

CI integration

CI is where env configuration fails most often, because the developer machine that “works” carries state that the runner does not. The git-ignored .env.local and .env.*.local files never reach the runner, process.env on the runner is whatever the CI provider injected, and the checkout is clean. The reliable pattern is to make the build environment explicit at the top of the job: inject every required VITE_-prefixed value as a CI secret before vite build runs, pass --mode explicitly, and let the config-level guards above turn any missing value into a failed job rather than a broken artifact.

# .github/workflows/deploy.yml — build-time env for a staging deploy
# Values are injected into the BUILD step's environment, before Rollup inlines them.
jobs:
  build-staging:
    runs-on: ubuntu-latest
    env:
      DEPLOY_TARGET: staging
      VITE_API_URL: ${{ secrets.STAGING_API_URL }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - run: npm ci
      # --mode staging loads .env.staging; the injected VITE_ vars override it.
      - run: npx vite build --mode staging
      # Acceptance gate: literal present, no raw references survived.
      - run: |
          grep -rq "$VITE_API_URL" dist/assets/*.js
          test "$(grep -rc 'import.meta.env' dist/assets/*.js | paste -sd+ | bc)" -eq 0

The ordering matters: a value exported after the build, or in the deploy job rather than the build job, exists in the wrong process and is never inlined — the single most common CI env bug. Injected shell variables that carry the VITE_ prefix take precedence over .env.staging for the same key, which is the intended override path for secrets you do not want committed even to a mode file. Keep the .env.staging in the repo as the source of non-secret defaults and let CI supply only the values that must not live in git.

When build-time env is the wrong tool

Static inlining is the right model for values that are fixed at build time and identical for every viewer of a given deployment: the API host, the public keys, the feature flags cut for this release. It is the wrong model for anything that must vary after the bundle is built. A value that differs per request, per tenant, or per user cannot be a build-time literal, because there is exactly one bundle and it was frozen once. The same is true for any configuration you want to change without rebuilding and redeploying — flipping a feature flag through import.meta.env means a full rebuild, which is far too slow for an operational toggle.

For those cases the value must arrive at runtime through a channel the browser can read live: a small /config.json fetched on boot, values templated into index.html by the server that hosts it, or a headers-based mechanism at the edge. The tell that you have reached this boundary is wanting to change an import.meta.env value without shipping new JavaScript — the moment you want that, the value belongs in a runtime config source, not a .env file. Reserve build-time env for what is genuinely constant for the life of the artifact, and push everything dynamic to a runtime lookup.

Compatibility matrix

Env behaviour by Vite major Vite 5.0 to 5.4 stabilised loadEnv, envPrefix and the full precedence chain; Vite 6 improved SSR env handling and made import.meta.env.SSR reliable in more contexts. Vite 5.0–5.4loadEnv + precedence stable Vite 6.xreliable import.meta.env.SSR Constant across versions: .env.local skipped in test; vite build forces NODE_ENV=production
Figure: the env pipeline is stable across 5.x; the SSR-env reliability improvement is the reason to prefer 6.x for server rendering.
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