Vite 10 ⚡ Environment Variables — import.meta.env & .env files
Vite exposes environment variables through a special import.meta.env object that is statically replaced at build time. The mechanism is deliberately narrow: only variables prefixed with VITE_ are exposed to client code, and every value is a string regardless of how it was written in the .env file. The prefix is not a convention—it is the security boundary that prevents a database password from being bundled into the JavaScript that ships to the browser.
This chapter covers the built-in constants that Vite provides, the .env file loading order and its mode-specific priority, the VITE_ prefix rule and its consequences, the loadEnv function for reading variables inside vite.config.js, and the TypeScript augmentation that makes custom variables autocomplete in the editor.
Key point: Only VITE_-prefixed variables reach import.meta.env. An unprefixed variable like DB_PASSWORD is loaded into the process environment but is undefined in client code. The prefix is the boundary between server-side configuration and client-side code.
Why the prefix exists
The bundle leakage problem. Vite’s client code is bundled and served to the browser. Any value that appears in that bundle is visible to anyone who opens the developer tools. If every environment variable were exposed, a .env file containing DB_PASSWORD=secret would ship the password to every visitor. The VITE_ prefix forces the developer to make an explicit decision: this variable is safe to be public .
The string coercion problem. Environment variables are strings. A .env file with VITE_SOME_KEY=123 produces "123", not 123. A boolean VITE_DEBUG=true produces "true", not true. The documentation warns explicitly: “Make sure to convert to the desired type when using it in your code” . This is not a bug; it is how dotenv works, and Vite does not add type inference on top of it.
The mode problem. A Vite application runs in a “mode”: development for vite dev, production for vite build, and any custom mode passed with --mode. The mode determines which .env files are loaded. A .env.production file can contain a production API URL, while .env.development contains the local one. The mode is not the same as NODE_ENV, though the two usually align .
a. Built-in constants
Vite provides five constants under import.meta.env that are available in every application, without any .env file.
| Constant | Type | Value |
|---|---|---|
import.meta.env.MODE | string | The current mode (development, production, or custom) |
import.meta.env.BASE_URL | string | The base URL from the base config |
import.meta.env.PROD | boolean | true in production |
import.meta.env.DEV | boolean | true in development (opposite of PROD) |
import.meta.env.SSR | boolean | true when running in SSR context |
These constants are statically replaced at build time. A block guarded by if (import.meta.env.DEV) is tree-shaken out of the production bundle, because the condition is replaced with false and the dead code is eliminated .
if (import.meta.env.DEV) {
console.log('Development mode')
}
b. The .env files and their loading order
Vite uses dotenv to load variables from four files in the environment directory (the project root by default).
| File | Loaded when | Git |
|---|---|---|
.env | Always | Committed |
.env.local | Always | Ignored |
.env.[mode] | Only in the specified mode | Committed |
.env.[mode].local | Only in the specified mode | Ignored |
The priority order is: mode-specific files override generic files, and .local files override non-local files. An environment variable that already exists in the shell when Vite starts has the highest priority and is not overwritten by any .env file .
# .env
VITE_API_URL=https://api.example.com
# .env.production
VITE_API_URL=https://api.production.example.com
# vite build loads .env and .env.production.
# VITE_API_URL resolves to the production value.
The documentation notes that .env files are loaded at Vite startup. Changing a .env file requires restarting the dev server; HMR does not pick up the change .
c. The VITE_ prefix and variable access
Only variables prefixed with VITE_ are exposed to client code. The access is through import.meta.env.VITE_NAME.
# .env
VITE_API_URL=https://api.example.com
DB_PASSWORD=secret
console.log(import.meta.env.VITE_API_URL) // "https://api.example.com"
console.log(import.meta.env.DB_PASSWORD) // undefined
The variable must be accessed with the full static dot notation. Destructuring does not work, because Vite replaces the literal string import.meta.env.VITE_API_URL at build time. A dynamic access like import.meta.env[key] or const { VITE_API_URL } = import.meta.env is not statically analyzable and produces undefined .
// Works
const url = import.meta.env.VITE_API_URL
// Does NOT work
const { VITE_API_URL } = import.meta.env
The prefix can be customized with the envPrefix option, but the documentation warns against exposing unprefixed variables. The default is a security feature, not a limitation .
d. loadEnv for vite.config.js
import.meta.env is not available inside vite.config.js. The loadEnv function loads the .env files for a given mode and returns an object containing the variables.
// vite.config.js
import { defineConfig, loadEnv } from 'vite'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
return {
server: {
proxy: {
'/api': env.API_BACKEND_URL,
},
},
}
})
The third argument to loadEnv is the prefix filter. An empty string '' loads all variables, including unprefixed ones. A 'VITE_' filter loads only the client-safe ones. The distinction matters: inside the config file, unprefixed variables are accessible and appropriate, because the config file is not bundled for the browser .
Complete Example Session
# ============================================
# PART 1: THE .ENV FILE
# ============================================
# .env
VITE_API_URL=https://api.example.com
VITE_APP_TITLE=My App
DB_PASSWORD=secret
// ============================================
// PART 2: CLIENT ACCESS
// ============================================
console.log(import.meta.env.VITE_API_URL) // "https://api.example.com"
console.log(import.meta.env.VITE_APP_TITLE) // "My App"
console.log(import.meta.env.DB_PASSWORD) // undefined
// ============================================
// PART 3: BUILT-IN CONSTANTS
// ============================================
console.log(import.meta.env.MODE) // "development" or "production"
console.log(import.meta.env.DEV) // true in dev, false in build
console.log(import.meta.env.PROD) // false in dev, true in build
// ============================================
// PART 4: TYPE CONVERSION
// ============================================
// .env
VITE_PORT=3000
VITE_DEBUG=true
// Client code
const port = Number(import.meta.env.VITE_PORT) // 3000 (number)
const debug = import.meta.env.VITE_DEBUG === 'true' // true (boolean)
# ============================================
# PART 5: MODE-SPECIFIC FILES
# ============================================
# .env.development
VITE_API_URL=http://localhost:3001
# .env.production
VITE_API_URL=https://api.production.com
# vite dev loads .env.development
# vite build loads .env.production
# ============================================
# PART 6: LOCAL OVERRIDES
# ============================================
# .env.local (gitignored)
VITE_API_URL=http://localhost:9999
# .env.local overrides .env and .env.[mode]
# Add *.local to .gitignore
# ============================================
# PART 7: SHELL OVERRIDE
# ============================================
VITE_API_URL=https://override.example.com vite build
# The shell variable has the highest priority.
# It is not overwritten by any .env file.
// ============================================
// PART 8: loadEnv IN CONFIG
// ============================================
// vite.config.js
import { defineConfig, loadEnv } from 'vite'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
return {
server: {
proxy: { '/api': env.API_BACKEND_URL },
},
}
})
// ============================================
// PART 9: TYPESCRIPT INTELLISENSE
// ============================================
// src/vite-env.d.ts
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string
readonly VITE_APP_TITLE: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
// ============================================
// PART 10: TREE-SHAKING WITH BUILT-INS
// ============================================
if (import.meta.env.DEV) {
// This block is removed from the production bundle.
setupDevTools()
}
The ten parts covered the .env file, client access, built-in constants, type conversion, mode-specific files, local overrides, shell override, loadEnv, TypeScript augmentation, and tree-shaking.
Quick Reference
Built-in constants
| Constant | Type | Purpose |
|---|---|---|
MODE | string | Current mode |
BASE_URL | string | Base URL from config |
PROD | boolean | Production flag |
DEV | boolean | Development flag |
SSR | boolean | SSR context flag |
.env file priority
| Priority | File | Git |
|---|---|---|
| 1 (highest) | Shell environment | N/A |
| 2 | .env.[mode].local | Ignored |
| 3 | .env.[mode] | Committed |
| 4 | .env.local | Ignored |
| 5 | .env | Committed |
Prefix rules
| Variable | Client access |
|---|---|
VITE_API_URL | import.meta.env.VITE_API_URL |
DB_PASSWORD | undefined |
VITE_DEBUG | import.meta.env.VITE_DEBUG (string) |
loadEnv signature
| Argument | Purpose |
|---|---|
mode | Which .env.[mode] to load |
envDir | Directory containing .env files |
prefix | Filter ('' for all, 'VITE_' for client-safe) |
Best Practices
✅ Do This:
# Prefix client-safe variables with VITE_
VITE_API_URL=https://api.example.com # ✅
// Convert string values to the expected type
const port = Number(import.meta.env.VITE_PORT) # ✅
// Augment ImportMetaEnv for TypeScript autocomplete
interface ImportMetaEnv { readonly VITE_API_URL: string } # ✅
# Use .env.local for local overrides
# Add *.local to .gitignore # ✅
❌ Don’t Do This:
# Put secrets in VITE_ variables
VITE_DB_PASSWORD=secret # ❌ (bundled to client)
// Destructure import.meta.env
const { VITE_API_URL } = import.meta.env # ❌ (undefined)
// Assume numeric values are numbers
if (import.meta.env.VITE_DEBUG) { ... } // "false" is truthy # ❌
# Commit .env.local files
# They contain machine-specific overrides # ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
Variable is undefined in client | Missing VITE_ prefix | Rename with prefix |
| Numeric value is a string | dotenv parses all values as strings | Convert with Number() or parseInt() |
| Boolean is always truthy | "false" is a non-empty string | Compare to 'true' or 'false' |
Variable is undefined after .env edit | Dev server needs restart | Restart vite dev |
Destructuring returns undefined | Vite replaces static dot access only | Use full import.meta.env.VITE_X |
| Secret appears in bundle | Prefixed with VITE_ | Move to server-side code or a backend proxy |
Real-World Examples
1. API URL
const apiUrl = import.meta.env.VITE_API_URL
2. Mode check
if (import.meta.env.PROD) { enableAnalytics() }
3. Numeric conversion
const port = Number(import.meta.env.VITE_PORT)
4. Boolean conversion
const debug = import.meta.env.VITE_DEBUG === 'true'
5. Mode-specific file
# .env.production
VITE_API_URL=https://api.production.com
6. Local override
# .env.local
VITE_API_URL=http://localhost:3001
7. loadEnv in config
const env = loadEnv(mode, process.cwd(), '')
8. TypeScript augmentation
interface ImportMetaEnv { readonly VITE_API_URL: string }
9. Shell override
VITE_API_URL=https://override.com vite build
10. Tree-shaking
if (import.meta.env.DEV) { setupDevTools() }
Visual
The .env loading pipeline
┌─────────────────────────────────────────────────────────────┐
│ .ENV LOADING │
│ │
│ Shell environment (highest priority) │
│ │ │
│ ▼ │
│ .env.[mode].local (gitignored) │
│ │ │
│ ▼ │
│ .env.[mode] │
│ │ │
│ ▼ │
│ .env.local (gitignored) │
│ │ │
│ ▼ │
│ .env │
│ │
│ Later files do not override earlier ones. │
└─────────────────────────────────────────────────────────────┘
The VITE_ prefix boundary
┌─────────────────────────────────────────────────────────────┐
│ CLIENT BUNDLE │
│ │
│ import.meta.env.VITE_API_URL ✅ exposed │
│ import.meta.env.DB_PASSWORD ❌ undefined │
│ │
│ Only VITE_ variables enter the bundle. │
│ Unprefixed variables stay in the Node process. │
└─────────────────────────────────────────────────────────────┘
Static replacement
┌─────────────────────────────────────────────────────────────┐
│ BUILD TIME │
│ │
│ Source: │
│ if (import.meta.env.DEV) { setupDevTools() } │
│ │
│ Replaced: │
│ if (false) { setupDevTools() } │
│ │
│ Tree-shaken: │
│ (removed) │
│ │
│ The production bundle contains no DEV block. │
└─────────────────────────────────────────────────────────────┘
loadEnv vs import.meta.env
┌─────────────────────────────────────────────────────────────┐
│ CONTEXT │
│ │
│ vite.config.js: │
│ import.meta.env is NOT available │
│ use loadEnv(mode, dir, prefix) │
│ │
│ src/ (client code): │
│ import.meta.env.VITE_* is available │
│ static replacement at build time │
└─────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Access object | import.meta.env |
| Client prefix | VITE_ |
| Built-in constants | MODE, BASE_URL, PROD, DEV, SSR |
.env files | .env, .env.local, .env.[mode], .env.[mode].local |
| Priority | Shell > mode-local > mode > local > generic |
| Value type | Always string |
| Config file access | loadEnv(mode, dir, prefix) |
| TypeScript augmentation | interface ImportMetaEnv |
Key takeaways:
- Only
VITE_-prefixed variables reach client code. The prefix is the security boundary between server-side configuration and the client bundle. An unprefixed variable is loaded into the process environment but isundefinedinimport.meta.env. - All values are strings.
VITE_PORT=3000produces"3000", andVITE_DEBUG=falseproduces"false", which is truthy. Convert explicitly withNumber(),parseInt(), or a string comparison . - The
.envfile priority is mode-specific..env.productionoverrides.env, and.env.localoverrides both. A shell variable overrides everything, which is how CI pipelines inject environment-specific values without modifying files . import.meta.envis not available invite.config.js. TheloadEnvfunction loads the.envfiles for the given mode and returns an object with the variables. An empty prefix string loads all variables, including unprefixed ones, which is appropriate inside the config file .- The access must be static dot notation. Vite replaces the literal string
import.meta.env.VITE_API_URLat build time. Destructuring or dynamic key access is not statically analyzable and returnsundefined. - TypeScript augmentation provides autocomplete. An
ImportMetaEnvinterface in a.d.tsfile adds the custom variables to the type ofimport.meta.env, which makes the editor flag typos and suggest the available keys .
Remember: Vite’s environment variable system is a security boundary disguised as a convenience. The VITE_ prefix is not a naming convention; it is the mechanism that prevents a database password from being bundled into the JavaScript that ships to every browser. Use the prefix for public configuration, keep secrets unprefixed and server-side, convert the string values to the types the code expects, and use loadEnv when the config file needs access. The built-in constants (DEV, PROD, MODE, BASE_URL, SSR) are available everywhere and are statically replaced, which means a if (import.meta.env.DEV) block costs nothing in the production bundle.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!