Modes and Environment Files
How modes pick which .env file loads, which variables are inlined into the bundle, and why a secret in there is published.
vite build --mode staging / import.meta.env.VITE_API_URLA mode is a name a Vite command runs under, and it decides which environment files load. vite (the dev server) uses mode development. vite build uses mode production. vite build --mode staging is the same build under a third name.
Which files load
Vite reads .env, then .env.local, then .env.[mode], then .env.[mode].local. Later files win. A variable already present in the process environment beats all of them, which is how a CI job overrides a value without editing a file. The .local files are git-ignored by the scaffold and hold machine-specific values.
Which variables reach the client
Only keys carrying the VITE_ prefix are exposed on import.meta.env. The prefix is the envPrefix option and VITE_ is its default. Vite adds MODE, BASE_URL, DEV, PROD and SSR itself. Inside index.html the same values are available as %VITE_API_URL%.
Inlined means published
These values are not read at runtime. They are substituted into the bundle as literal strings while it is built. A VITE_API_SECRET therefore sits in a file any visitor can download and search. Public identifiers are fine; anything secret belongs on a server.
Two traps. import.meta is not usable in a template, so read it in <script setup> and expose a constant. And --mode development does not change NODE_ENV, which stays production for a build.
Example
<script setup lang="ts">
import { ref } from 'vue'
// Read env in script; templates cannot see import.meta.
const apiBase: string = import.meta.env.VITE_API_URL
const mode: string = import.meta.env.MODE
const isProduction: boolean = import.meta.env.PROD
const status = ref<'idle' | 'checking' | 'ok' | 'error'>('idle')
async function check(): Promise<void> {
status.value = 'checking'
try {
const response = await fetch(`${apiBase}/health`)
status.value = response.ok ? 'ok' : 'error'
} catch {
status.value = 'error'
}
}
</script>
<template>
<section class="env-check">
<p v-if="!isProduction">Mode: {{ mode }} · API: {{ apiBase }}</p>
<button type="button" @click="check">Check API</button>
<p>Status: {{ status }}</p>
</section>
</template>
<style scoped>
.env-check {
font-size: 0.875rem;
}
</style>When to use it
- Point the app at a staging API without changing code, using .env.staging and --mode staging.
- Hide a development-only debug panel behind import.meta.env.DEV so it is dropped from the production bundle.
- Override VITE_API_URL from CI environment variables instead of committing a per-branch env file.
- Audit a bundle for a leaked key by grepping dist/assets for the value of a VITE_ variable.
More examples
The env files for three modes
Four files, one variable, resolved by precedence. Put defaults in .env, per-environment overrides in .env.[mode], and anything specific to your laptop in .env.local. The last entry shows the prefix doing its job: an unprefixed key is still loaded by Vite, but it never reaches import.meta.env and so never reaches the browser.
# .env (committed, applies to every mode)
VITE_API_URL=http://localhost:8080
VITE_APP_TITLE=Soundscode
# .env.production (loaded by `vite build`)
VITE_API_URL=https://api.example.com
# .env.staging (loaded by `vite build --mode staging`)
VITE_API_URL=https://staging-api.example.com
VITE_APP_TITLE=Soundscode (staging)
# .env.local (git-ignored, overrides .env on this machine; a mode file such as
# .env.production still wins over it, so the per-machine override for one mode
# goes in .env.production.local)
VITE_API_URL=http://192.168.1.24:8080
# Not exposed to the client: no VITE_ prefix, so it stays out of the bundle.
# Only tools reading process.env, such as a config file, can see it.
DEPLOY_TOKEN=keep-this-on-the-serverTyping the variables (env.d.ts)
Without this, import.meta.env.VITE_API_URL types as string and a typo types as undefined with no complaint. With it, vue-tsc fails on a name you have not declared, which turns a blank page in production into a failed build. The file must contain no import statements, or the interface merging does not apply.
// env.d.ts
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string
readonly VITE_APP_TITLE: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}Using env values inside vite.config.ts
Reach for loadEnv when the config itself needs a value, for example a dev port or a proxy target. process.env does not contain your .env entries at this point, which is the usual surprise. Note that define does the same substitution as a VITE_ variable, so anything placed there is equally public.
// vite.config.ts
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig(({ mode }) => {
// Env files are loaded after the config is resolved, so ask for them.
// The third argument '' loads every key, not just VITE_-prefixed ones.
const env = loadEnv(mode, process.cwd(), '')
return {
plugins: [vue()],
server: {
port: env.APP_PORT ? Number(env.APP_PORT) : 5173,
},
define: {
__APP_ENV__: JSON.stringify(env.APP_ENV),
},
}
})
Discussion