1.5 · difficulty 2/4 · 16 min read
Env Variables and Modes
Understand import.meta.env, .env priority, VITE_ prefixes, envPrefix, loadEnv, HTML replacements, and the difference between mode and NODE_ENV.
import.meta.env Is Compile-Time Data
Vite exposes environment data on import.meta.env:
if (import.meta.env.DEV) {
console.log("development only")
}
if (import.meta.env.PROD) {
startAnalytics()
}During dev these are globals. During build they are statically replaced, which lets dead branches be removed.
if (import.meta.env.DEV) {
console.log("debug panel")
}In production, import.meta.env.DEV becomes false, and the minifier can remove the branch.
Built-In Constants
| Constant | Type | Meaning |
|---|---|---|
import.meta.env.MODE | string | active mode, such as development |
import.meta.env.BASE_URL | string | public base path from the base config |
import.meta.env.DEV | boolean | development environment |
import.meta.env.PROD | boolean | production environment |
import.meta.env.SSR | boolean | running in a server environment |
BASE_URL is easy to miss. If the app is deployed under a sub-path, avoid hard-coding URLs:
const url = `${import.meta.env.BASE_URL}assets/logo.png`It must appear as a literal expression. import.meta.env["BASE_URL"] is not handled the same way.
.env Loading Priority
Vite loads env files by mode:
.env
.env.local
.env.[mode]
.env.[mode].localFor:
vite build --mode stagingThink of the order as:
.env
.env.local
.env.staging
.env.staging.local
existing shell environment variablesLater values win. Variables passed in the shell have the highest priority:
VITE_API_URL=https://api.example.com vite build --mode stagingRestart the dev server after changing .env files. Env values may already have affected config resolution, dependency optimization, and compile-time replacements.
Only VITE_ Reaches Browser Code
VITE_API_URL=https://api.example.com
DB_PASSWORD=local-secretBrowser code can read only the prefixed value:
console.log(import.meta.env.VITE_API_URL)
console.log(import.meta.env.DB_PASSWORD) // undefinedThis is a security boundary. Vite does not expose all process environment variables by default, because database passwords, tokens, and private keys must not end up in client bundles.
Custom Prefixes with envPrefix
import { defineConfig } from "vite"
export default defineConfig({
envPrefix: ["VITE_", "APP_"],
})Never set envPrefix to an empty string. That would expose every environment variable to the client.
Mode Is Not NODE_ENV
| Command | NODE_ENV | import.meta.env.MODE |
|---|---|---|
vite dev | "development" | "development" |
vite build | "production" | "production" |
vite build --mode staging | "production" | "staging" |
NODE_ENV=development vite build | "development" | "production" |
Mode answers: "Which .env.[mode] file should be loaded?"
NODE_ENV answers: "Should tooling use development or production semantics?"
So vite build --mode staging is still a production build. It just reads staging configuration.
Reading Env in vite.config.ts
Application code uses import.meta.env; config files run in Node and should use loadEnv:
import { defineConfig, loadEnv } from "vite"
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "")
return {
server: {
proxy: {
"/api": env.API_PROXY_TARGET,
},
},
}
})The third argument is the prefix filter. "VITE_" loads client-exposed variables. "" loads all variables. Reading private values in config is fine; leaking them through define or client code is not.
HTML Constant Replacement
<title>%VITE_APP_TITLE%</title> <meta name="app-mode" content="%MODE%" />Missing HTML constants remain unchanged, while import.meta.env.NON_EXISTENT in JS becomes undefined. For complex HTML injection, use the transformIndexHtml plugin hook instead.
TypeScript IntelliSense
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string
readonly VITE_APP_TITLE: string
readonly VITE_ENABLE_MOCK?: "true" | "false"
}
interface ImportMeta {
readonly env: ImportMetaEnv
}This gives completion and catches misspelled variable names earlier.
Recommended Layering
| File | Commit it? | Typical content |
|---|---|---|
.env | yes | public defaults |
.env.local | no | local overrides |
.env.staging | yes | public staging config |
.env.production | yes | public production config |
.env.production.local | no | deployment-only overrides |
Ask one question before adding VITE_: is it acceptable if every user can see this value? If not, do not expose it.
Check Yourself
- Why is
DB_PASSWORDnot available onimport.meta.envby default? - How is
vite build --mode stagingdifferent fromNODE_ENV=development vite build? - Should
vite.config.tsuseimport.meta.envorloadEnv? - How do missing constants differ between HTML and JS?
// Design staging env config:
// - browser code can read VITE_API_URL
// - vite.config.ts can read API_PROXY_TARGET
// - TypeScript completes VITE_API_URL
// - no private token leaks to the client