Environment Variables Undefined in Vite: Forget process.env
1. Why Am I Seeing Undefined?
You just migrated your React project from CRA (Create React App) or Webpack to Vite, or maybe you set up a fresh Vite project.
You added your API Key to .env, but when you verify it in the console, it prints undefined.
// .env
API_KEY=my-secret-key
// App.tsx
console.log(process.env.API_KEY); // undefined!
console.log(import.meta.env.API_KEY); // undefined!
You check the file syntax, move the .env file to the root directory, restart the server... nothing works.
This happens because Vite's philosophy on environment variables differs fundamentally from Node.js or Webpack.
2. Root Cause: process.env Does Not Exist in Browsers
The biggest misconception is believing that "Frontend code can access process.env".
- The
processobject is a global object that exists ONLY in the Node.js runtime. - Browsers (Chrome, Safari, Edge) have no concept of
process.
But Why Did It Work in CRA/Webpack?
In CRA or Webpack projects, the build tool scans your source code. When it sees process.env.REACT_APP_XXX, it literally Text-Replaces that string with the actual value defined in your .env file at build time.
It was a magic trick. The browser never saw process. It only saw the string value.
Vite performs a similar magic trick, but uses a different incantation (Syntax) based on modern ES Modules standards: import.meta.env.
3. The Solution: Prefix and Syntax
To expose environment variables in Vite, you must follow two strict rules.
Rule 1: Add the VITE_ Prefix
By default, Vite does not expose .env variables to the client bundle. This is a security feature to prevent accidental leakage of sensitive backend secrets (like DB passwords).
Any variable intended for the browser MUST start with VITE_.
# ❌ Not accessible in client (Server-side/Build scripts only)
DB_PASSWORD=secret1234
API_KEY=hidden-key
# ✅ Accessible in client
VITE_API_URL=https://api.myapp.com
VITE_ANALYTICS_ID=UA-12345678-1
Rule 2: Use import.meta.env
In your code, access variables via import.meta.env, not process.env.
// App.tsx
// ❌ WRONG
const apiUrl = process.env.VITE_API_URL;
// ✅ CORRECT
const apiUrl = import.meta.env.VITE_API_URL;
console.log(`API Target: ${apiUrl}`);
4. Deep Dive: Adding TypeScript Intellisense
If you use TypeScript, you might notice that import.meta.env.VITE_... doesn't autocomplete.
You need to extend the type definitions.
Step 1: Create vite-env.d.ts
Create (or edit) src/vite-env.d.ts:
/// <reference types="vite/client" />
interface ImportMetaEnv {
// Define your env vars here
readonly VITE_API_URL: string;
readonly VITE_APP_TITLE: string;
readonly VITE_FIREBASE_KEY: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
Step 2: Check tsconfig.json
Ensure "vite/client" is included in compilerOptions.types.
{
"compilerOptions": {
"types": ["vite/client"]
}
}
Now, VS Code will provide full autocomplete support for your custom env vars.
5. Practical Guide: Handling Multiple Environments (Staging vs Prod)
Real-world applications have multiple environments: Local, Dev, Staging, Production.
Vite supports this elegantly via .env.mode files.
Priority (Lower overrides Higher)
.env(Loaded everywhere).env.local(Git ignored, local overrides).env.development(Only in dev).env.production(Only in prod).env.staging(Only in staging mode)
Configuration Example
.env.staging (For QA Testing)
VITE_API_URL=https://stg-api.myapp.com
VITE_ENV_NAME=staging
.env.production (For Live Users)
VITE_API_URL=https://api.myapp.com
VITE_ENV_NAME=production
Script Setup (package.json)
Use the --mode flag to specify which file to load during build.
"scripts": {
"dev": "vite",
"build": "vite build", // Loads .env.production by default
"build:staging": "vite build --mode staging" // Loads .env.staging
}
Now, npm run build:staging produces a build pointing to your staging API.
6. Docker & Runtime Environment Variables (Crucial for DevOps)
Here is where many developers get stuck. "Vite Environment Variables are baked in at BUILD TIME."
If you build a Docker image with VITE_API_URL=http://localhost:8080, that URL is hardcoded into the JS files.
You cannot change it by simply setting ENV VITE_API_URL=https://prod.api in your Dockerfile or Kubernetes config when running the container. The HTML/JS is already static.
The Solution: Runtime Injection Pattern
To support "Build Once, Deploy Anywhere", use the window injection pattern.
-
Create
public/config.jswindow.ENV = { API_URL: "DEFAULT_URL_FOR_DEV" }; -
Load in
index.html<head> <script src="/config.js"></script> </head> -
Use in App
// Use window.ENV with fallback const apiUrl = window.ENV?.API_URL || import.meta.env.VITE_API_URL; -
Entrypoint Script (
entrypoint.sh) When the container starts, usesedto replace the placeholder inconfig.jswith the real environment variable from the OS.#!/bin/sh # Replace placeholder with real env var sed -i "s|DEFAULT_URL_FOR_DEV|$API_URL|g" /usr/share/nginx/html/config.js # Start server nginx -g "daemon off;"
This ensures your Docker image is truly environment-agnostic.
7. Monorepo Setup (TurboRepo / Nx)
If you are in a Monorepo, you likely have a shared .env at the root.
Vite only looks for .env files in the Project Root (where vite.config.ts lives) by default.
To load the root .env:
import { defineConfig, loadEnv } from 'vite';
import path from 'path';
export default defineConfig(({ mode }) => {
// Look for .env in the parent's parent directory
const env = loadEnv(mode, path.resolve(__dirname, '../../'), '');
return {
// Optional: Explicitly define shared vars
define: {
'import.meta.env.VITE_SHARED_KEY': JSON.stringify(env.VITE_SHARED_KEY)
}
};
});
Deep Dive Glossary
1. Tree Shaking
A term commonly used in the JavaScript context for dead-code elimination. It relies on the static structure of ES2015 module syntax (import and export). Bundlers like Vite (via Rollup) remove unused code from the final bundle.
2. Dead Code Elimination
The process where the compiler removes code that will never be executed. For example, if import.meta.env.MODE is 'production', the block if (import.meta.env.MODE === 'development') { ... } is completely removed from the production build.
3. ESM (ECMAScript Modules)
The official standard format to package JavaScript code for reuse. Vite serves source code over native ESM in development, which makes it incredibly fast compared to bundlers that bundle everything before serving (like Webpack).
4. IIFE (Immediately Invoked Function Expression)
A JavaScript function that runs as soon as it is defined. The window.ENV injection pattern often uses an IIFE in config.js to avoid polluting the global scope, although attaching directly to window is necessary for accessibility across the app.
8. Summary: Remember These 5 Things
- Prefix is Mandatory: Start regular variables with
VITE_. - Syntax Shift: Forget
process.env. Useimport.meta.env. - Restart Server: Environment variables are loaded on startup. Restart
npm run devafter edits. - Type It: Use
vite-env.d.tsfor sanity. - Docker Strategy: Use runtime injection (
window.ENV) for containerized deployments.
Once you understand the distinction between Build Time (Vite) and Run Time (Node.js/Docker), handling environment variables becomes straightforward and secure.