Fix: Vite Environment Variables Not Working
Part of: React & Frontend Errors
Quick Answer
How to fix Vite environment variables showing as undefined — missing VITE_ prefix, wrong .env file for the mode, import.meta.env vs process.env, TypeScript types, and SSR differences.
The Error
An environment variable is undefined in your Vite application:
console.log(import.meta.env.VITE_API_URL); // undefined
console.log(import.meta.env.MY_SECRET); // undefined
console.log(process.env.VITE_API_URL); // undefined or TypeErrorOr TypeScript reports an error:
Property 'VITE_API_URL' does not exist on type 'ImportMetaEnv'Or the variable is available in development but undefined after vite build.
Why This Happens
Vite’s environment variable system has specific rules that differ from Node.js, webpack, and Create React App. Variables are not read at runtime — they are statically replaced in the source by esbuild during transform, then frozen into the bundle. That means console.log(import.meta.env.VITE_API_URL) becomes console.log("https://api.example.com") in the output JavaScript. If the variable was missing at build time, it becomes console.log(undefined), and no amount of restarting the server at runtime will recover it.
The static-replacement model also explains why only the VITE_ prefix is exposed. Without it, every secret in your .env — including database passwords your vite.config.ts reads via loadEnv for proxy configuration — would risk being inlined into the JavaScript bundle and shipped to every visitor. The prefix is an explicit opt-in: by writing VITE_, you assert that the value is safe to be public.
A third gotcha is the per-mode file loading. Vite resolves the mode at build time and only loads files matching .env, .env.local, .env.{mode}, and .env.{mode}.local. If you build with vite build you implicitly pick the production mode and .env.development is never read; if you build with vite build --mode staging you must have a .env.staging (or set the variable in shared .env).
- Missing
VITE_prefix — only variables prefixed withVITE_are exposed to client-side code. Variables without this prefix are intentionally hidden for security.MY_SECRETis undefined;VITE_MY_SECRETworks. - Using
process.envinstead ofimport.meta.env— Vite doesn’t polyfillprocess.env. Useimport.meta.envin client code. - Wrong
.envfile for the mode — Vite loads different.envfiles depending on the--modeflag. Runningvite(development) loads.env.development, runningvite build(production) loads.env.production. A variable in.env.developmentwon’t be available in production builds. .envfile not in the project root — Vite looks for.envfiles in the project root by default (wherevite.config.jsis). Files in subdirectories are ignored.- TypeScript type definitions missing —
import.meta.env.VITE_*works at runtime but TypeScript shows an error becauseImportMetaEnvisn’t extended with your variable names. - SSR mode differences — in server-side rendering,
import.meta.envbehaves differently and some variables may need different handling.
Version History That Changes the Failure Mode
Vite’s environment variable handling has shifted across every major release. Tutorials written for Vite 2 still circulate on Stack Overflow and Medium, and copying them into a Vite 5 or 6 project produces variables that “almost work.”
Vite 2.0 (February 2021). Introduced import.meta.env and the VITE_ prefix convention. Replaced the earlier process.env pattern that Vite 1 (then called vite-app) had inherited from snowpack-style tooling. Node 12+ was supported.
Vite 3.0 (July 2022). Dropped Node 12 in favour of Node 14.18+ and Node 16+. The default dev server port changed from 3000 to 5173 — unrelated to env vars directly, but enough to break some .env setups that hard-coded the dev URL.
Vite 4.0 (December 2022). Bumped to esbuild 0.16, TypeScript 5-friendly. Tightened the SSR exports field handling, which started affecting how import.meta.env worked in server entries that imported from packages with mixed CJS/ESM exports.
Vite 5.0 (November 2023). This is the cliff. Vite 5 is ESM-only for both the config file and the runtime, and it dropped Node 16 support — Node 18+ is required. vite.config.js written with module.exports = ... stops working; it must use export default defineConfig(...). The loadEnv helper still works the same way, but if you imported it via require('vite'), that import line breaks. Older .env files keep working, but the define config option became stricter about how values are serialized.
Vite 5.1 / 5.2 (2024). Added the experimental.renderBuiltUrl hook, refined import.meta.env typing.
Vite 6.0 (November 2024). Introduced the Environment API, which allows multiple build environments (client, SSR, edge, RSC) in a single config. import.meta.env is now scoped per environment, and the way loadEnv interacts with non-client environments changed. Code that worked in Vite 5 — for example, reading VITE_* directly from a Node entry point — may need updating if you migrate that entry to a non-client environment. Node 18 remains the minimum, with Node 20 recommended.
define vs import.meta.env. Vite 2 documented define as the workaround for non-VITE_ variables. Vite 3+ recommends loadEnv inside defineConfig and explicit define entries for each variable. The pattern that “spreads” all loaded env into define (define: { 'process.env': env }) has always been documented as insecure — it inlines every variable into the bundle, including secrets. Newer Vite versions still allow it but warn in the docs.
TypeScript types. The shape of ImportMetaEnv has accreted across versions. BASE_URL, MODE, DEV, PROD, and SSR were not all present in early 2.x; the recommended /// <reference types="vite/client" /> triple-slash directive is the safe path on every modern version.
Fix 1: Add the VITE_ Prefix
This is the most common cause. Rename your variables in the .env file:
# .env — WRONG (not exposed to client)
API_URL=https://api.example.com
SECRET_KEY=abc123
# .env — CORRECT (VITE_ prefix exposes to client)
VITE_API_URL=https://api.example.com
# Leave truly secret variables without VITE_ — they stay server-only
SECRET_KEY=abc123 # Still usable in vite.config.js but NOT in browser code// In your component/page
const apiUrl = import.meta.env.VITE_API_URL;
console.log(apiUrl); // "https://api.example.com" ✓Why the prefix? Vite statically replaces
import.meta.env.VITE_*at build time. Without the prefix, secrets like database passwords and API keys in your.envcould be accidentally bundled and exposed in the client JavaScript. The prefix is an explicit opt-in for client exposure.
Fix 2: Use import.meta.env, Not process.env
Vite uses import.meta.env for environment variables in client code, not process.env:
// WRONG — process.env is not available in Vite client code
const apiUrl = process.env.VITE_API_URL; // undefined
// CORRECT
const apiUrl = import.meta.env.VITE_API_URL;Vite does replace process.env.NODE_ENV as a special case, but this is the only process.env variable you can rely on. For everything else, use import.meta.env.
Migration from Create React App:
| CRA | Vite |
|---|---|
REACT_APP_API_URL | VITE_API_URL |
process.env.REACT_APP_API_URL | import.meta.env.VITE_API_URL |
process.env.NODE_ENV | import.meta.env.MODE |
process.env.PUBLIC_URL | import.meta.env.BASE_URL |
Fix 3: Use the Right .env File for Each Mode
Vite loads .env files based on the current mode:
| File | When loaded |
|---|---|
.env | Always |
.env.local | Always, overrides .env (not committed to git) |
.env.development | vite (dev server) |
.env.development.local | vite dev server, local override |
.env.production | vite build |
.env.production.local | vite build, local override |
.env.staging | vite --mode staging |
Common mistake: Putting variables in .env.development then wondering why they’re undefined after vite build. Add variables to both files or put shared values in .env:
# .env — shared across all modes
VITE_APP_NAME=MyApp
# .env.development — dev only
VITE_API_URL=http://localhost:8080
# .env.production — production only
VITE_API_URL=https://api.example.comUse a custom mode:
# Build with staging mode
vite build --mode staging
# Create .env.staging
VITE_API_URL=https://staging.api.example.com// Check the current mode
console.log(import.meta.env.MODE); // "development", "production", or "staging"Fix 4: Check the .env File Location
Vite looks for .env files in the root directory — where vite.config.js or vite.config.ts lives. If your project structure nests these differently:
project/
├── vite.config.ts ← Vite root
├── .env ← Correct location
├── src/
│ └── .env ← Wrong — ignored by Vite
└── frontend/
└── .env ← Wrong if vite.config.ts is in project/Override the env directory in vite.config.ts:
import { defineConfig } from 'vite';
export default defineConfig({
envDir: './config/env', // Load .env files from this directory instead
});Verify which file Vite is reading by adding a unique test variable and logging it:
# .env
VITE_DEBUG_CHECK=loaded_from_root
# Then in your app
console.log(import.meta.env.VITE_DEBUG_CHECK); // "loaded_from_root" if correctFix 5: Add TypeScript Type Definitions
If TypeScript shows Property 'VITE_API_URL' does not exist on type 'ImportMetaEnv', add type declarations:
// src/vite-env.d.ts (create this file)
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_APP_NAME: string;
readonly VITE_FEATURE_FLAG: string;
// Add all your VITE_ variables here
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}This file also enables autocomplete in editors. After adding it, restart the TypeScript language server.
Make variables optional if they might not be defined:
interface ImportMetaEnv {
readonly VITE_API_URL: string; // Required
readonly VITE_FEATURE_FLAG?: string; // Optional
}Validate at runtime:
// src/config.ts
const apiUrl = import.meta.env.VITE_API_URL;
if (!apiUrl) {
throw new Error('VITE_API_URL is not defined. Check your .env file.');
}
export const config = {
apiUrl,
appName: import.meta.env.VITE_APP_NAME ?? 'MyApp',
};Fix 6: Use define for Non-VITE_ Variables
For variables you want injected at build time without the VITE_ prefix, use the define option in vite.config.ts:
import { defineConfig, loadEnv } from 'vite';
export default defineConfig(({ mode }) => {
// Load all env variables (including non-VITE_ ones)
const env = loadEnv(mode, process.cwd(), '');
return {
define: {
// Explicitly expose specific non-VITE_ variables
'process.env.BUILD_DATE': JSON.stringify(new Date().toISOString()),
'__APP_VERSION__': JSON.stringify(process.env.npm_package_version),
// Only expose what you explicitly choose — don't spread all of env
},
};
});// In your app code
console.log(__APP_VERSION__); // "1.2.3"
console.log(process.env.BUILD_DATE); // "2026-03-19T..."Warning: Never expose database credentials, private API keys, or secrets via define. The values are inlined into the bundle and visible to anyone who downloads your JavaScript.
Fix 7: Fix Environment Variables in SSR
In Vite SSR (server-side rendering), environment variables work differently. Variables available via import.meta.env on the server are all env vars (not just VITE_ prefixed), but this does not carry over to client hydration:
// server.ts (SSR)
// All process.env variables are available here via import.meta.env
const dbUrl = import.meta.env.DATABASE_URL; // Works in SSR
// But DATABASE_URL is still not in the client bundleFor frameworks like SvelteKit or Nuxt with Vite:
// SvelteKit — use $env/static/private for server-only
import { DATABASE_URL } from '$env/static/private'; // Server only
// $env/static/public for client-safe
import { PUBLIC_API_URL } from '$env/static/public'; // Client + serverStill Not Working?
Restart the dev server after changing .env files. Vite does not hot-reload environment variable changes. Stop the server (Ctrl+C) and run vite again.
Check for .env syntax errors. Values don’t need quotes for simple strings, but special characters can cause issues:
# Fine
VITE_API_URL=https://api.example.com
# Needs quotes if value contains spaces or special chars
VITE_APP_DESCRIPTION="My App with spaces"
VITE_REGEX="^[a-z]+$"Check .gitignore. If .env.local is in .gitignore (it should be), make sure you actually created the file locally:
ls -la .env*
# Should show .env and .env.local (if you created it)Verify the variable appears in the bundle. In vite build output, check dist/assets/*.js for your variable value. If it’s been statically replaced, you’ll see the actual string, not import.meta.env.VITE_API_URL. If you see undefined, the variable wasn’t set during build.
Check your Vite major version against your config file. If package.json shows Vite 5 or 6 but vite.config.js uses module.exports = ..., the config file silently fails to load and Vite uses defaults — including loading no .env files. Convert the config to ESM (export default defineConfig(...)) or rename to vite.config.mjs.
Check that your deploy platform actually injects the variable. Vercel, Netlify, Cloudflare Pages, and AWS Amplify each have their own UI for “build-time environment variables.” Setting them in the dashboard is mandatory — they are not read from a .env file checked into Git (which you should not do anyway). Trigger a fresh build after adding them; cached builds still ship the old undefined value.
Check for .env files being ignored by the build container. Some CI systems mount the repo without .env files because they appear in .gitignore. Use loadEnv plus explicit define entries that pull from real process env, not from .env files, for CI builds.
For related Vite issues, see Fix: Vite Failed to Resolve Import, Fix: Vite HMR Connection Lost, Fix: Vite Proxy Not Working, and Fix: Next.js Environment Variables Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Vite Proxy Not Working — API Requests Not Forwarded or 404/502 Errors
How to fix Vite dev server proxy issues — proxy configuration in vite.config.ts, path rewriting, WebSocket proxying, HTTPS targets, and common misconfigurations.
Fix: Webpack/Vite Path Alias Not Working — Module Not Found with @/ Prefix
How to fix path alias errors in webpack and Vite — configuring resolve.alias, tsconfig paths, babel-plugin-module-resolver, Vite alias configuration, and Jest moduleNameMapper.
Fix: CodeMirror Not Working — Editor Not Rendering, Extensions Not Loading, or React State Out of Sync
How to fix CodeMirror 6 issues — basic setup, language and theme extensions, React integration, vim mode, collaborative editing, custom keybindings, and read-only mode.
Fix: GSAP Not Working — Animations Not Playing, ScrollTrigger Not Firing, or React Cleanup Issues
How to fix GSAP animation issues — timeline and tween basics, ScrollTrigger setup, React useGSAP hook, cleanup and context, SplitText, stagger animations, and Next.js integration.