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.
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.
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.
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.
--mode staging changes which env loads, not whether the output is production-optimized.Mode resolution chain
- CLI flag:
vite --mode stagingorvite build --mode staging. - Default mode:
developmentforvite/vite dev,productionforvite build/vite preview. .envfiles load for the resolved mode following the precedence chain (see below).import.meta.env.MODEis set to the resolved mode string;DEV,PROD, andSSRare 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.
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
- Create
.envwith safe defaults:VITE_API_URL=http://localhost:3000. - Create
.env.stagingwithVITE_API_URL=https://staging.example.com. - Add
VITE_API_URLtosrc/vite-env.d.tsso the compiler tracks it. - Read it in app code:
const url = import.meta.env.VITE_API_URL. - Build for staging:
vite build --mode staging. - 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
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
| 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 |
Related
- Vite Configuration & Ecosystem — the overview that frames config resolution, the layer that runs before env substitution.
- Managing multiple .env files across Vite environments — precedence overrides and monorepo
envDirsharing. - Fixing import.meta.env undefined in production builds — the prefix and build-time-presence failures in detail.
- Advanced Vite Plugin Configuration — reading finalized env inside
configResolved. - Optimizing Vite Dev Server and HMR — why env edits need a restart and how mode affects cold start.