| | |

Vite 10 ⚡ Environment Variables — import.meta.env & .env files

⏱️ Estimated read: 11 min | |

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.

ConstantTypeValue
import.meta.env.MODEstringThe current mode (development, production, or custom)
import.meta.env.BASE_URLstringThe base URL from the base config
import.meta.env.PRODbooleantrue in production
import.meta.env.DEVbooleantrue in development (opposite of PROD)
import.meta.env.SSRbooleantrue 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).

FileLoaded whenGit
.envAlwaysCommitted
.env.localAlwaysIgnored
.env.[mode]Only in the specified modeCommitted
.env.[mode].localOnly in the specified modeIgnored

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

ConstantTypePurpose
MODEstringCurrent mode
BASE_URLstringBase URL from config
PRODbooleanProduction flag
DEVbooleanDevelopment flag
SSRbooleanSSR context flag

.env file priority

PriorityFileGit
1 (highest)Shell environmentN/A
2.env.[mode].localIgnored
3.env.[mode]Committed
4.env.localIgnored
5.envCommitted

Prefix rules

VariableClient access
VITE_API_URLimport.meta.env.VITE_API_URL
DB_PASSWORDundefined
VITE_DEBUGimport.meta.env.VITE_DEBUG (string)

loadEnv signature

ArgumentPurpose
modeWhich .env.[mode] to load
envDirDirectory containing .env files
prefixFilter ('' 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

PitfallWhy It HappensFix
Variable is undefined in clientMissing VITE_ prefixRename with prefix
Numeric value is a stringdotenv parses all values as stringsConvert with Number() or parseInt()
Boolean is always truthy"false" is a non-empty stringCompare to 'true' or 'false'
Variable is undefined after .env editDev server needs restartRestart vite dev
Destructuring returns undefinedVite replaces static dot access onlyUse full import.meta.env.VITE_X
Secret appears in bundlePrefixed 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

ItemValue
Access objectimport.meta.env
Client prefixVITE_
Built-in constantsMODE, BASE_URL, PROD, DEV, SSR
.env files.env, .env.local, .env.[mode], .env.[mode].local
PriorityShell > mode-local > mode > local > generic
Value typeAlways string
Config file accessloadEnv(mode, dir, prefix)
TypeScript augmentationinterface 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 is undefined in import.meta.env .
  • All values are strings. VITE_PORT=3000 produces "3000", and VITE_DEBUG=false produces "false", which is truthy. Convert explicitly with Number(), parseInt(), or a string comparison .
  • The .env file priority is mode-specific. .env.production overrides .env, and .env.local overrides both. A shell variable overrides everything, which is how CI pipelines inject environment-specific values without modifying files .
  • import.meta.env is not available in vite.config.js. The loadEnv function loads the .env files 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_URL at build time. Destructuring or dynamic key access is not statically analyzable and returns undefined .
  • TypeScript augmentation provides autocomplete. An ImportMetaEnv interface in a .d.ts file adds the custom variables to the type of import.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.

Was this helpful?


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!