When import.meta.env.VITE_API_URL logs undefined, your fetch hits undefined/users and the app 404s before it renders. The cause is almost always one of eight things, and seven of them are not bugs in Vite — they are the static-replacement model working exactly as designed. Vite reads .env files once at startup and inlines each VITE_ value as literal text at build time (confirmed current in Vite 8.1, June 2026), so a missing prefix, a stale dev server, or a dynamic key lookup all collapse to the same symptom. Run your file through the env validator to rule out syntax first, then work down this list.
TL;DR
- Only
VITE_-prefixed variables reachimport.meta.env. Everything else isundefinedin client code by design — that is the security boundary, not a bug. - In the browser,
process.envdoes not exist. Useimport.meta.env.VITE_X, neverprocess.env.VITE_X. .envis read once at startup. Edit it and the running dev server keeps the old value until you restart it.- Replacement is literal text substitution.
import.meta.env[key]andimport.meta?.env?.VITE_Xdefeat it and returnundefined. - The
.envfile must sit in the project root next tovite.config.ts, not insrc/.
Quick diagnosis: symptom → cause → fix
| Symptom | Cause | Fix |
|---|---|---|
| Variable always undefined, dev and prod | Missing VITE_ prefix | Rename to VITE_* or set envPrefix |
process.env.VITE_X is undefined in the browser | process.env does not exist client-side | Read import.meta.env.VITE_X |
| Worked before, undefined after editing .env | Dev server cached the old value | Stop and restart the dev server |
| Every variable is undefined | .env is in src/, not root | Move it beside vite.config.ts |
| Works in dev, undefined in the production bundle | Dynamic import.meta.env[key] or optional chaining | Use the literal dotted form |
| Undefined only after deploy | Value set on host dashboard after build | Set it before build, then rebuild |
| Undefined only in Vitest | Dependency ships "type": "module" | Inject via test.env or a setup file |
1. Did you forget the VITE_ prefix?
Vite exposes only variables that start with VITE_ to client code. Everything else is loaded into the Node process but deliberately withheld from import.meta.env so a stray DB_PASSWORD cannot leak into the shipped bundle. This is the single most common cause of an undefined variable.
// .env
VITE_API_URL=https://api.example.com
API_KEY=sk_live_123 // loaded, but never exposed to client code
// src/api.ts
console.log(import.meta.env.VITE_API_URL); // "https://api.example.com"
console.log(import.meta.env.API_KEY); // undefined — no VITE_ prefixFor the full picture of how the prefix and static replacement work, see the companion Vite environment variables guide.
2. Are you reading process.env instead of import.meta.env?
In a browser bundle there is no process object, so process.env.VITE_API_URL is undefined (and on older setups throws ReferenceError: process is not defined). Vite uses import.meta.env precisely so it can statically replace the reference at build time — there is no runtime process.env to read. Developers migrating from Create React App or Webpack hit this constantly.
// WRONG — process.env is undefined in the browser with Vite
const url = process.env.VITE_API_URL; // undefined (or ReferenceError)
// CORRECT
const url = import.meta.env.VITE_API_URL;Server-side code in an SSR framework (TanStack Start, Nuxt, SvelteKit) is the exception: there process.env is real and readable at runtime, the same way it is in plain Node.js. The rule only bites in code that runs in the browser.
3. Did you restart the dev server after editing .env?
Vite reads .env files once, when the server boots. It hot-reloads your source, not your environment, so a variable you added five seconds ago stays undefined until you stop the process and start it again. There is no watch-and-reload for .env by default.
# After editing any .env file:
# Ctrl+C to stop, then start again
npm run dev4. Is the .env file in the project root?
Vite loads .env files from the project root — the directory that holds vite.config.ts and package.json — not from src/. Putting the file under src/ is the exact mistake in Vite discussion #15525, where every variable read as undefined until the file moved up one level.
my-app/
├── vite.config.ts
├── package.json
├── .env # HERE — project root
└── src/
├── .env # NOT here — Vite never reads this
└── main.tsIf your .env genuinely lives elsewhere (a monorepo package, a shared config dir), point Vite at it with the envDir option in vite.config.ts rather than moving the file.
5. Are you accessing the key dynamically or with optional chaining?
Replacement is literal text substitution, not a runtime object lookup. Vite rewrites the exact string import.meta.env.VITE_API_URL into the inlined value; anything it cannot match as a static reference is left alone and evaluates to undefined in the build. The official docs state it plainly: dynamic key access like import.meta.env[key] will not work. The trap is that dynamic access often does work in dev (where import.meta.env is a real object) and only breaks in production — Vite 6+ module runners now throw Dynamic access of "import.meta.env" is not supported to surface it earlier.
// All of these break static replacement → undefined in the production bundle
const key = 'VITE_API_URL';
import.meta.env[key]; // dynamic key
import.meta?.env?.VITE_API_URL; // optional chaining (see discussion #15525)
const { VITE_API_URL } = import.meta.env; // destructuring before replacement
// CORRECT — the literal dotted form is what Vite rewrites
import.meta.env.VITE_API_URL;
// Need dynamic lookup? Build a map of literal references first:
const env = { apiUrl: import.meta.env.VITE_API_URL, cdn: import.meta.env.VITE_CDN };
env[someKey];6. Did you edit the right .env file for the mode?
vite runs in development mode and loads .env.development; vite build runs in production mode and loads .env.production. A value you put in .env.development is simply not present when you build. More specific files win, and a variable already set in the shell or CI beats all of them:
| File | Loaded when | Priority |
|---|---|---|
.env.[mode].local | Matching mode only, git-ignored | Highest file |
.env.[mode] | Matching mode only | High |
.env.local | Always, git-ignored | Medium |
.env | Always | Lowest |
# Reproduce what the build actually sees
vite build --mode production # loads .env.production
vite build --mode staging # loads .env.staging (production-grade output)7. Did you set the value after the build?
Because VITE_ values are inlined at vite build time, adding a variable to your hosting dashboard (Netlify, Vercel, Cloudflare Pages) does nothing for a bundle that was already built — the placeholder was resolved, or left undefined, when the artifact was produced. Set the variable in the build environment before the build step runs, then rebuild and redeploy. This is the build-time-versus-runtime split that catches every static front end.
If you truly need one artifact to serve many environments, do not bake config in at all — see build-time vs runtime env variables for the runtime-injection pattern.
8. Is TypeScript typing it as any?
A variable that compiles and runs fine can still look broken in the editor: without an ImportMetaEnv augmentation, every custom key is typed as any, and with strict settings TypeScript may flag the access. Declare your variables in src/vite-env.d.ts:
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_SENTRY_DSN: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}One sharp edge from the official docs: the augmentation silently stops working if vite-env.d.ts contains a top-level import statement, because that turns the file from an ambient declaration into a module. Keep it import-free. Note the types promise a string, but Vite does not error on a missing variable — issue #8021 (still pending triage in 2026) asks for exactly that, so validate required variables at startup yourself.
Changing the prefix with envPrefix
If your variables use a different prefix (a shared PUBLIC_ convention, for instance), tell Vite with envPrefix. It defaults to VITE_ and accepts an array. Vite refuses to start if you set it to an empty string, because that would expose your entire environment — every CI token and shell secret — to the client bundle.
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
envPrefix: ['VITE_', 'PUBLIC_'], // now PUBLIC_FOO is also exposed
// envPrefix: '' // Vite throws — would leak every secret
});When undefined is correct, not a bug
- Unprefixed variables in client code — withheld on purpose so secrets do not ship. If you see
undefinedforDATABASE_URL, the boundary is doing its job; move that read to the server. - A secret you wanted in the browser — there is no safe way. Every
VITE_value is plain text in DevTools. Proxy the call through a backend; the env vars security guide covers the threat model. - Undefined inside vite.config.ts — expected. The config runs in Node before
.envis parsed; useloadEnv(mode, process.cwd(), '')instead ofimport.meta.env. - Undefined only in Vitest — a known edge case (vitest #2970): when a dependency declares
"type": "module", Vitest may not injectimport.meta.envinto it. Define the value intest.envor a setup file rather than editing the dependency.
Quick debugging checklist
- Confirm the variable starts with
VITE_ - Confirm you read
import.meta.env.VITE_X, notprocess.env.VITE_X - Restart the dev server after any .env edit
- Confirm
.envis in the project root, notsrc/ - Use the literal dotted form — no
[key], optional chaining, or destructuring - Check the file matches the mode (
vite buildreads.env.production) - For deploys, set the variable before the build runs, then rebuild
Frequently Asked Questions
Why is import.meta.env.VITE_X undefined?
Work down eight causes: the variable lacks the VITE_ prefix, you read process.env instead of import.meta.env, the dev server was not restarted after editing .env, the .env file sits in src/ instead of the project root, the key is accessed dynamically (import.meta.env[key]) or with optional chaining, the value is in the wrong .env.[mode] file, it was set on the host dashboard after the build, or TypeScript types it as any. The first six produce a true undefined at runtime.
Why does process.env.VITE_X not work in Vite?
There is no process object in the browser bundle, so process.env.VITE_X is undefined (or throws "process is not defined"). Vite exposes client-side variables on import.meta.env, which it statically replaces at build time. Use import.meta.env.VITE_X in any code that runs in the browser; process.env is only readable in server-side code under an SSR framework.
Why does my Vite variable work in dev but is undefined in production?
Almost always dynamic access. import.meta.env is a real object in dev, so import.meta.env[key], optional chaining, or destructuring all read fine there. In a production build Vite replaces only literal references like import.meta.env.VITE_X, so anything dynamic becomes undefined. Use the full static dotted form, or build a lookup map of literal references.
Where does the .env file go in a Vite project?
In the project root, next to vite.config.ts and package.json — not in src/. Placing it under src/ makes every variable read as undefined (Vite discussion #15525). If the file must live elsewhere, set the envDir option in vite.config.ts instead of moving it.
Do I need to restart the Vite dev server after changing .env?
Yes. Vite reads .env files once at startup and does not watch them. Edits are invisible to the running server until you stop it and start it again. Vite hot-reloads source code, not environment variables.
Why is import.meta.env.VITE_X undefined only in my Vitest tests?
When a dependency package declares "type": "module" in its package.json, Vitest may fail to inject import.meta.env into that package (vitest issue #2970, closed as not planned). Rather than editing the dependency, define the value through Vitest config (test.env) or a setup file so the variable is present during the test run.
References
- Vite docs — Env Variables and Modes — the VITE_ prefix rule, file priority, modes, and the explicit note that dynamic key access does not work
- Vite docs — shared options (envPrefix, envDir) — prefix defaults and why an empty prefix throws
- Vite discussion #15525 — import.meta.env undefined in the console — real report; .env in src/ and optional-chaining as the causes
- Vite issue #8021 — error on undefined VITE_ variables — open request explaining why Vite stays silent on missing variables
- Vitest issue #2970 — import.meta.env undefined in tests — the
"type": "module"dependency edge case
Rule out a malformed .env with the env validator, or read the .env file guide for quoting and syntax rules that silently break parsing.