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.

Syntaxvite build --mode staging / import.meta.env.VITE_API_URL

A 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

Example · vue
<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 }} &middot; 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.

Example · ini
# .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-server

Typing 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.

Example · typescript
// 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.

Example · typescript
// 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

  • Be the first to comment on this lesson.