import.meta.env: las variables que Astro hereda de Vite
El mecanismo base para leer configuración en Astro viene de Vite: import.meta.env. Por qué el prefijo PUBLIC_ decide qué cruza al navegador y qué se queda en el servidor, qué son las banderas MODE, DEV y PROD que el build inyecta, cómo se cargan y priorizan los archivos .env por modo, y por qué la sustitución es estática en tiempo de compilación y no una lectura dinámica en ejecución.
Antes de estrenar su propio sistema tipado, Astro ya heredaba de Vite un mecanismo para leer configuración: import.meta.env. No es un invento de Astro, sino una propiedad estándar del módulo que Vite rellena en tiempo de compilación con las variables de tu entorno y con un puñado de banderas sobre cómo se está construyendo el proyecto. Entenderlo bien es entender una frontera invisible pero crítica —la que separa lo que puede viajar al navegador de lo que jamás debe salir del servidor— y descubrir que esa frontera no la traza un permiso en ejecución, sino una convención de nombres y una sustitución de texto que ocurre mucho antes de que nadie visite tu sitio.
- Leer variables con
import.meta.envy distinguir públicas de privadas por el prefijoPUBLIC_. - Usar las banderas integradas
MODE,DEVyPRODque Vite inyecta según el comando. - Cargar y priorizar archivos
.envpor modo, incluyendo las variantes.local. - Comprender que la sustitución es estática en el build, no una lectura dinámica en ejecución.
El prefijo PUBLIC_ traza la frontera cliente/servidor
En Astro, cualquier variable de entorno es legible desde el código de servidor —el frontmatter de una página, un endpoint, el middleware— con import.meta.env.NOMBRE. Pero solo las que empiezan por PUBLIC_ llegan al código que se empaqueta para el navegador. Esta asimetría es deliberada y constituye la primera línea de defensa contra la fuga de secretos: si tu credencial se llama DATABASE_URL, ningún bundle de cliente la contendrá jamás, porque Vite se niega a incrustar en el navegador cualquier nombre que no lleve el prefijo acordado.
// Codigo de servidor: ambas se leen sin problema
const publica = import.meta.env.PUBLIC_API_URL; // disponible en cliente y servidor
const privada = import.meta.env.DATABASE_URL; // solo en el servidor
Lo que hace especial a PUBLIC_ no es un chequeo en tiempo de ejecución, sino una decisión de empaquetado. Cuando Vite construye el bundle del navegador, recorre el código en busca de accesos literales a import.meta.env.PUBLIC_... y los reemplaza por su valor como una cadena. Las variables sin prefijo no existen en ese universo: leer import.meta.env.DATABASE_URL dentro de un <script> de cliente devuelve undefined, no porque algo la oculte en el último momento, sino porque nunca se incrustó en primer lugar.
Es fácil confundirse porque ambos conviven en el mismo archivo .astro. El bloque entre las tres rayas se ejecuta en el servidor y ve todas las variables; un <script> que se hidrata en el navegador solo ve las PUBLIC_. Si necesitas un valor privado en el cliente, no lo hay: o lo expones como público asumiendo que será visible, o lo consumes desde el servidor y envías al navegador únicamente el resultado ya procesado, nunca la credencial en crudo.
Las banderas que describen el build: MODE, DEV, PROD
Además de tus variables, Vite inyecta un conjunto de banderas de solo lectura que describen bajo qué condiciones se compila el proyecto. No provienen de ningún archivo .env: las rellena el propio proceso según el comando que lanzaste.
import.meta.env.MODE; // 'development' con astro dev, 'production' con astro build
import.meta.env.DEV; // true en desarrollo, false en el build de produccion
import.meta.env.PROD; // false en desarrollo, true en el build de produccion
import.meta.env.BASE_URL; // la base configurada del sitio (opcion base)
import.meta.env.SSR; // true cuando el codigo corre del lado del servidor
DEV y PROD son booleanos siempre opuestos, y su valor se conoce en el build. Eso permite algo más profundo que una simple condición: como Vite sustituye la bandera por una constante, un bloque envuelto en if (import.meta.env.PROD) que no se cumple queda como código muerto y el minificador lo elimina del bundle. Escribes una guarda legible y obtienes, gratis, poda de código según el destino.
MODE
Cadena con el modo activo. Por defecto development o production, pero puedes inventar el tuyo con la bandera –mode.
DEV
Booleano verdadero solo durante astro dev. Ideal para logs y avisos que no deben llegar a produccion.
PROD
Booleano verdadero solo en el build final. Envuelve en el codigo que sobra para que se pode del bundle.
SSR
Booleano que distingue si el modulo se evalua en el servidor o en el navegador durante la hidratacion.
El modo no está limitado a esos dos valores. Con astro build --mode staging fuerzas MODE a staging, lo que además cambia qué archivos .env se cargan. Esta es la palanca que separa entornos sin tocar el código: el mismo build, alimentado por distintos archivos, produce artefactos configurados para destinos distintos.
Cuando solo quieres saber si estás en desarrollo o en producción, las banderas booleanas DEV y PROD son más claras que comparar MODE con un literal y no se rompen por una errata en el nombre del modo. Ambas formas se resuelven en el build y permiten podar la rama que no aplica, pero un booleano expresa la intención de un vistazo, mientras que una cadena obliga a leer con cuidado y a confiar en que está bien escrita. Reserva MODE para cuando de verdad manejas más de dos modos, como al añadir staging.
Astro suma sus propias variables al conjunto
A las banderas de Vite, Astro añade variables derivadas de tu configuración, también accesibles por import.meta.env. Describen la identidad y la ubicación del sitio, y te ahorran reimportar la configuración solo para consultarlas.
import.meta.env.SITE; // la URL declarada en la opcion site de la config
import.meta.env.BASE_URL; // la subruta base si sirves bajo un prefijo
import.meta.env.ASSETS_PREFIX; // el prefijo de assets cuando usas un CDN aparte
Estas no se definen en un .env: nacen de astro.config.mjs, son de solo lectura y su valor queda fijado en el build, igual que las banderas. Sirven para construir URLs absolutas, resolver rutas de assets o generar enlaces canónicos sin cablear el dominio a mano en cada plantilla.
Hay además una regla de precedencia fácil de olvidar: si una variable ya existe en el entorno real del proceso, ese valor manda sobre lo que digan tus archivos .env. Vite no pisa lo que el sistema ya definió. Es justo el comportamiento que quieres en despliegue, donde la plataforma inyecta las variables de verdad y ningún .env que se colara en la imagen debería sobreescribirlas.
Un mismo módulo puede evaluarse dos veces: una en el servidor al renderizar y otra en el navegador al hidratar. import.meta.env.SSR vale true solo en la primera. Es la bandera que te permite confinar al lado correcto el código que toca recursos de servidor —leer un secreto, abrir una conexión—, para que no intente ejecutarse en el navegador, donde ni existiría ni tendría sentido.
Una forma sana de interiorizar todo esto es comprobarlo en el artefacto. Tras un astro build, busca en la carpeta de cliente el valor de una variable PUBLIC_ y lo hallarás incrustado como texto; busca el de una privada y no aparecerá por ningún lado. Esa inspección convierte la frontera de la que hablamos en un hecho que ves con tus ojos, y es el hábito que más rápido calma la ansiedad de no estar seguro de qué acabó expuesto. Con esa base firme, la siguiente lección da el salto natural: dejar de confiar en un prefijo por convención y declarar cada variable en un esquema que el compilador valida.
Archivos .env y su orden de prioridad
Vite carga automáticamente los archivos .env de la raíz del proyecto y expone su contenido a través de import.meta.env. No hay una sola convención, sino una jerarquía pensada para separar lo compartido de lo secreto y lo genérico de lo específico de cada modo.
.env # cargado siempre, en cualquier modo
.env.local # cargado siempre, pero ignorado por git
.env.[mode] # cargado solo en ese modo (development, production, staging)
.env.[mode].local # cargado solo en ese modo, ignorado por git
Cuando una misma clave aparece en varios archivos, gana el más específico y el más local. La prioridad, de mayor a menor, es .env.[mode].local, luego .env.[mode], luego .env.local y por último .env. La regla operativa se resume en dos ideas: los .local guardan lo que no se comparte y por eso viven fuera del control de versiones, y los archivos por modo sobreescriben a los genéricos para adaptar valores a cada entorno.
# .env -> valor por defecto compartido por todo el equipo
PUBLIC_API_URL=https://api.ejemplo.com
# .env.development -> apunta al backend local durante el desarrollo
PUBLIC_API_URL=http://localhost:8787
# .env.local -> secretos que solo existen en tu maquina
DATABASE_URL=postgres://usuario:clave@localhost/db
flowchart LR ENV[archivos punto env por modo] --> VITE[Vite carga y fusiona por prioridad] VITE --> IME[import meta env] IME --> SRV[bundle de servidor: todas las claves] IME --> CLI[bundle de cliente: solo las PUBLIC] style IME fill:#89b4fa,color:#11111b style SRV fill:#a6e3a1,color:#11111b style CLI fill:#f9e2af,color:#11111b
Como en el navegador import.meta.env.PUBLIC_X se reemplaza por texto durante el build, no puedes construir el nombre de la clave en ejecución. Un acceso dinámico como import.meta.env['PUBLIC_' + nombre] no encuentra nada que sustituir y falla en el cliente, porque no hay un objeto real que recorrer: solo hubo un buscar y reemplazar de literales. En el servidor sí existe el objeto y el acceso dinámico funciona; esa diferencia de naturaleza entre los dos entornos es la fuente de muchos desconciertos.
Puedes describir la forma de tus variables ampliando la interfaz ImportMetaEnv en src/env.d.ts. Con esa declaración, el editor te autocompleta PUBLIC_API_URL y te avisa si escribes mal una clave o esperas un booleano donde solo hay cadenas. Recuerda que todo lo que llega por import.meta.env es texto: los números y booleanos hay que convertirlos y validarlos a mano, un trabajo que la próxima lección delega en un esquema tipado.
Conviene cerrar con una advertencia operativa que enlaza con las buenas prácticas. Como Vite carga los .env de la raíz del proyecto, cualquier valor que escribas ahí acaba influyendo en el build, y por eso los archivos con secretos reales deben quedar fuera del control de versiones desde el primer día, no cuando ya hay algo delicado dentro. Un secreto que se sube una vez hay que darlo por comprometido para siempre, así que la prevención barata —ignorarlo antes de escribirlo— vale mil veces más que cualquier limpieza posterior.
Antes de escribir el primer secreto en un .env, asegúrate de que .env y sus variantes .local están en tu .gitignore. Es un gesto de diez segundos que evita el accidente más común y más difícil de deshacer: subir una credencial al repositorio. La plantilla .env.example, en cambio, sí se versiona, porque no contiene valores reales. Ese equilibrio entre lo que se comparte y lo que se oculta tendrá su lección propia, pero el hábito empieza aquí.
La lección honda de import.meta.env no es su sintaxis, sino lo que revela sobre cuándo se decide la seguridad. Uno esperaría que impedir que un secreto llegue al navegador fuese un control en tiempo de ejecución: un guardia que, en cada respuesta, inspecciona lo que sale y tacha lo prohibido. Pero aquí no hay guardia alguno. Lo que hay es una convención de nombres —el prefijo PUBLIC_— y una sustitución de texto que ocurre en el build, semanas antes de la primera visita. La frontera entre lo público y lo privado no se vigila: se hornea. Y esto solo funciona porque un build no es una copia del código, sino una proyección de él hacia un destino concreto. Vite no lleva tu proyecto entero al navegador; construye una versión del mundo en la que las variables privadas simplemente nunca existieron, un universo donde DATABASE_URL no es un secreto bien guardado sino un nombre que no denota nada. Comprender esto reordena tu modelo mental: dejas de pensar en la seguridad como algo que se aplica tarde, sobre datos que ya viajan, y empiezas a pensarla como algo que se decide temprano, eligiendo qué entra en cada proyección. La misma idea reaparece por toda la ingeniería madura: el minificador que poda el bloque de PROD no está optimizando en ejecución, está proyectando un mundo sin ese código; el compilador que borra los tipos no protege en runtime, proyecta un JavaScript donde los tipos nunca estuvieron. Cuando internalizas que tus herramientas construyen mundos a medida en lugar de transportar el original, entiendes por qué un prefijo puede ser un muro: no es que el muro sea débil por ser una mera convención, es que la convención gobierna qué existe al otro lado, y lo que no existe no se puede filtrar.
- Crea un
.envcon una clavePUBLIC_SALUDOy otraSECRETO_INTERNO, y léelas ambas en el frontmatter de una página; comprueba que las dos aparecen. - Añade un
<script>de cliente que intente imprimir ambas por consola y observa cuál saleundefineden el navegador. - Lanza
astro devy luegoastro buildmostrandoimport.meta.env.MODE,DEVyPROD, y anota cómo cambian según el comando. - Crea un
.env.staging, arranca con--mode stagingy verifica que el valor cargado es el de ese archivo y no el genérico.