wandres.dev
CONFIGURAR VITE · plugins y resolve

Entorno y modos: import.meta.env, .env, envPrefix y define

Cómo Vite inyecta configuración: qué vive en import.meta.env, la cascada de archivos .env, el prefijo público como frontera de seguridad con envPrefix, y define como sustitución literal en tiempo de build.

⏱ 18 min

Ninguna app seria lleva sus URLs, sus flags y sus claves públicas escritas a mano en el código: las recibe del entorno. Vite ofrece dos mecanismos para inyectar esa configuración, y confundirlos es una fuente clásica de fugas de secretos. import.meta.env con los archivos .env es el canal seguro y tipado, gobernado por un prefijo que actúa de frontera; define es una sustitución textual literal en tiempo de build, potente y sin red de seguridad. Esta lección traza la línea exacta entre lo que llega al navegador y lo que se queda fuera, y por qué esa línea es un asunto de seguridad, no de comodidad.

🎯 Al terminar esta lección sabrás
  • Saber qué contiene import.meta.env y qué variables llegan al cliente.
  • Ordenar la cascada de precedencia de los archivos .env.
  • Usar envPrefix entendiéndolo como frontera de seguridad.
  • Distinguir define de las variables de entorno y usarlo sin fugas.

import.meta.env: qué hay dentro

Vite expone la configuración en el objeto import.meta.env, disponible en cualquier módulo. Trae de fábrica un puñado de valores: MODE con el modo actual, BASE_URL con la ruta base pública, y los booleanos PROD, DEV y SSR. A partir de ahí, tus variables: pero solo llegan al cliente las que empiezan por el prefijo público VITE_. Una variable sin ese prefijo existe en el proceso de Node durante el build y es invisible para el navegador, por diseño.

console.log(import.meta.env.MODE)      // "development" | "production" | tu modo
console.log(import.meta.env.PROD)      // boolean
console.log(import.meta.env.BASE_URL)  // "/" por defecto
console.log(import.meta.env.VITE_API)  // tu variable, si empieza por VITE_
console.log(import.meta.env.DB_SECRET) // undefined en el cliente: sin prefijo

Ese undefined de la última línea no es un bug: es la barrera funcionando. La regla mental correcta es que import.meta.env en el navegador es un subconjunto deliberado del entorno, filtrado por prefijo, y que ese filtro es lo único que separa tus secretos de un bundle público que cualquiera puede descargar y leer.

Y hay una propiedad que multiplica su valor: esas lecturas se sustituyen estáticamente en el build. Un if sobre import.meta.env.PROD no se evalúa en runtime, sino que se resuelve en tiempo de compilación, de modo que el bloque de la rama muerta se elimina por completo del bundle. Es el mismo mecanismo que hace que el código de depuración envuelto en import.meta.env.DEV desaparezca sin dejar rastro en producción.

Archivos .env y la cascada de precedencia

Vite carga variables desde archivos .env en la raíz del proyecto, y hay cuatro variantes con una jerarquía precisa. .env se carga siempre; .env.local también siempre, pero se ignora en git y es donde van tus valores de máquina; .env.[mode] solo en ese modo; y .env.[mode].local combina ambas cosas. Cuando una misma variable aparece en varios, gana la más específica, y dentro de la misma especificidad gana la variante .local.

.env                # siempre
.env.local          # siempre, ignorado por git
.env.[mode]         # solo en ese modo, p.ej. .env.production
.env.[mode].local   # solo en ese modo, ignorado por git
flowchart TD
A[.env base] --> M[variables efectivas]
B[.env.local] --> M
C[.env.mode] --> M
D[.env.mode.local] --> M
M --> W[gana la mas especifica y .local sobre no local]
style W fill:#89b4fa,color:#11111b

Dentro de la config, a veces necesitas leer variables tú mismo —para pasarlas a un plugin o a define—. La utilidad loadEnv las carga con las mismas reglas, y su tercer argumento controla el prefijo: una cadena vacía carga todas, incluidas las que no son VITE_, algo que solo debes hacer dentro de la config, jamás filtrando esos valores al cliente.

import { defineConfig, loadEnv } from "vite"

export default defineConfig(({ mode }) => {
  // el tercer argumento "" carga TODAS, no solo las VITE_
  const env = loadEnv(mode, process.cwd(), "")
  return {
    define: {
      __API__: JSON.stringify(env.API_URL),
    },
  }
})
🏷️

MODE

El modo activo como cadena. La fuente de verdad para ramificar comportamiento por entorno.

🌐

BASE_URL

La ruta base pública. Cámbiala cuando sirves la app bajo un subdirectorio.

PROD y DEV

Booleanos opuestos derivados del modo. Ideales para eliminar código de dev en producción.

🔑

VITE_...

Tus variables públicas. El prefijo es lo único que las autoriza a viajar al navegador.

envPrefix, define y la frontera de lo que se filtra

envPrefix te deja cambiar o ampliar el prefijo público. Puedes añadir un segundo prefijo si tu equipo usa otra convención, pero hay una tentación que debes tratar como prohibida: ponerlo en cadena vacía. Eso haría todas las variables del entorno públicas, y volcaría tus secretos —tokens, claves de base de datos— directos al bundle que se descarga el navegador. El prefijo no es un detalle estético: es el interruptor que decide qué es público.

export default defineConfig({
  envPrefix: ["VITE_", "PUBLIC_"], // amplia el prefijo publico, con criterio
  define: {
    // OJO: reemplazo textual literal, hay que serializar el valor
    __APP_VERSION__: JSON.stringify("1.4.0"),
    "process.env.NODE_ENV": JSON.stringify("production"),
  },
})

define es un mecanismo distinto y más crudo: una sustitución de texto en tiempo de build. Vite busca la clave en tu código y la reemplaza literalmente por el valor que diste, como un buscar-y-reemplazar antes de compilar. De ahí la regla de oro: el valor tiene que ser una cadena de código válido, por eso se envuelve en JSON.stringify. Si escribes una cadena sin serializar, Vite inyecta el texto crudo y produce un error de sintaxis o, peor, una referencia a una variable que no existe.

⚠️
define no es una variable: es un reemplazo textual

La diferencia entre define y import.meta.env es de naturaleza, no de sintaxis. import.meta.env.VITE_X es una lectura de un objeto, tipada y filtrada por prefijo. define sustituye la clave por texto en cualquier parte del código, incluidas las dependencias, sin filtro ni tipos. Esa potencia es su peligro: un define mal serializado no falla en la config, falla en un módulo lejano en tiempo de build, y un define que expone un secreto lo estampa en el bundle igual que lo haría un prefijo vacío. Usa define para constantes de compilación —una versión, un flag booleano estático— y deja la configuración de verdad en el canal de import.meta.env.

Para que TypeScript conozca tus variables, amplía la interfaz que Vite provee. Con una referencia a los tipos de cliente y una interfaz aumentada, cada import.meta.env.VITE_API deja de ser any y pasa a estar tipado, y un typo se convierte en un error del editor en lugar de un undefined en runtime.

// src/vite-env.d.ts
/// <reference types="vite/client" />
interface ImportMetaEnv {
  readonly VITE_API: string
  readonly VITE_FEATURE_NEW_UI: string
}
interface ImportMeta {
  readonly env: ImportMetaEnv
}

Modos a medida y envDir

El poder real de la cascada aparece con modos propios. Un modo staging con su .env.staging te da un tercer entorno —compila optimizado como producción pero apunta a la API de pruebas— sin una sola rama en el código: solo archivos de entorno. Arrancas o construyes con la bandera de modo y Vite carga el .env.[mode] que corresponde, encima del .env base.

vite build --mode staging   # carga .env.staging sobre el .env base

Por defecto esos archivos viven en la raíz del proyecto, pero envDir reubica la carpeta —útil en monorepos donde la configuración de entorno se centraliza fuera de cada app—. Vivan donde vivan, la barrera del prefijo sigue en pie: solo las variables con prefijo público cruzan al cliente, y las demás se quedan en el proceso de build.

Hay además un caso de servidor que conviene tener claro: en SSR, import.meta.env.SSR vale true, y las variables sin prefijo público sí están disponibles en ese lado porque ese código nunca llega al navegador. La barrera del prefijo protege el bundle de cliente; el código de servidor, que corre en Node, puede leer el entorno completo, y mezclar ambos mundos sin cuidado es otra vía de fuga que el prefijo por sí solo no cierra.

💡
Qué se versiona y qué no

La regla que evita fugas: los .env y .env.[mode] se versionan y contienen solo valores públicos o por defecto; los .env.local y .env.[mode].local se ignoran en git y son el único sitio para valores de máquina. Meter un secreto en un .env.production versionado es publicarlo en el historial del repositorio para siempre, aunque nunca lleve el prefijo público. La frontera del prefijo protege el bundle; tu .gitignore protege el repositorio, y necesitas las dos a la vez.

El prefijo público es una decisión de seguridad disfrazada de convención

Parece una regla de estilo —pon VITE_ delante— pero es una de las decisiones de seguridad mejor diseñadas de todo el tooling moderno, y entender por qué te vacuna contra una clase entera de desastres. Un bundle de frontend es código público: se descarga entero al navegador de cualquiera, se puede abrir, leer y desminificar. No existe tal cosa como un secreto en el cliente; lo que pones ahí, lo publicas. El problema es que el entorno de tu proceso de build mezcla, en el mismo saco, cosas que deben ser públicas —la URL de tu API, un flag de feature— con cosas que jamás pueden serlo —una clave de base de datos, un token de servicio—. Sin una frontera, la línea entre ambas depende de que cada persona recuerde, cada vez, cuál es cuál, y esa memoria falla. El prefijo convierte esa frontera en algo estructural: por defecto nada llega al cliente, y solo cruza la barrera lo que marcas explícitamente con VITE_. La política pasa de lista negra —recuerda excluir los secretos— a lista blanca —solo lo marcado sale—, y ese giro es exactamente el mismo principio que hace segura cualquier frontera bien diseñada. Por eso poner envPrefix en vacío no es una opción avanzada: es desactivar el mecanismo, cambiar la lista blanca por que todo pase, y con ella la única red que impide que un despiste de una tarde acabe con la clave de producción incrustada en un archivo estático servido a medio mundo. Interiorizar esto es dejar de ver el prefijo como burocracia y empezar a verlo como lo que es: la línea, dibujada por el sistema y no por tu memoria, entre lo que el mundo puede ver y lo que no.

⚔️ Traza la frontera de tus variables
  1. Crea un .env con una variable VITE_API y otra DB_SECRET, y comprueba en el navegador que solo la primera aparece en import.meta.env.
  2. Añade un .env.production que sobrescriba VITE_API y verifica con un build que gana sobre el .env base.
  3. En la config, usa loadEnv con prefijo vacío para leer DB_SECRET y pásalo a un plugin sin exponerlo al cliente.
  4. Define una constante __APP_VERSION__ con define correctamente serializada y úsala en la app.
  5. Aumenta ImportMetaEnv con tus variables y confirma que un typo en el nombre ahora lo marca el editor.