
How to use astro:env for type-safe environment variables?
To use astro:env, declare your environment variables in the env.schema option of astro.config.mjs using the envField helpers, giving each variable a type (string, number, boolean or enum), a context ("client" or "server") and an access level ("public" or "secret"). Then import the variables from astro:env/client or astro:env/server instead of reading import.meta.env. Astro generates TypeScript types from the schema, converts values to the right type, validates them, and fails with a clear error listing every missing or invalid variable. Secrets are never bundled into your code; they're read from the server environment at runtime through your adapter.
astro:env fixes most of the problems with plain environment variables: everything being a string, values silently missing, and secrets ending up inside built files. In this article you'll learn how to define a schema, which of the three variable kinds to use, what each data type and validation option does, when validation happens, how to read secrets dynamically with getSecret(), how it works with adapters such as Node and Cloudflare, and the limitations to know about.
Defining a Schema
Add an env.schema object to your Astro config. Each key is a variable name, and each value describes it with envField:
// astro.config.mjs
import { defineConfig, envField } from "astro/config";
import node from "@astrojs/node";
export default defineConfig({
adapter: node({ mode: "standalone" }),
env: {
schema: {
PUBLIC_API_URL: envField.string({
context: "client",
access: "public",
url: true,
}),
ANALYTICS_ENABLED: envField.boolean({
context: "client",
access: "public",
default: false,
}),
CACHE_TTL: envField.number({
context: "server",
access: "public",
int: true,
min: 0,
default: 60,
}),
LOG_LEVEL: envField.enum({
context: "server",
access: "public",
values: ["debug", "info", "warn", "error"],
default: "info",
}),
STRIPE_SECRET_KEY: envField.string({
context: "server",
access: "secret",
startsWith: "sk_",
}),
},
},
});
You still set the values the usual way: in .env files, in the shell, or in your hosting dashboard. The schema only describes and checks them. If you're new to setting variables, start with how to set environment variables in Astro.
# .env
PUBLIC_API_URL=https://api.example.com
CACHE_TTL=300
ANALYTICS_ENABLED=false
LOG_LEVEL=info
Variables without optional: true or a default are required. Note that PUBLIC_ in the name isn't needed with astro:env, because context and access decide visibility, but keeping the prefix for client variables makes their nature obvious when you read the code.
The Three Kinds of Variables
context and access combine into three kinds:
| Kind | context | access | Import from | Where the value ends up |
|---|---|---|---|---|
| Public client | "client" | "public" | astro:env/client | Client and server bundles |
| Public server | "server" | "public" | astro:env/server | Server bundle |
| Secret server | "server" | "secret" | astro:env/server | Nowhere; read at runtime |
A fourth combination, client and secret, isn't allowed, because there's no safe way to send a secret to the browser.
Choosing between them:
- Public client: values the browser needs and that anyone may see, such as a public API URL or a feature flag for UI.
- Public server: non-sensitive server settings that are fixed per build, such as a cache duration or log level.
- Secret server: anything sensitive, such as API keys, database URLs and tokens, or any value you want to change without rebuilding.
Using the Variables
Import exactly what you need from the matching module:
---
// src/pages/config.astro
export const prerender = false;
import { PUBLIC_API_URL, ANALYTICS_ENABLED } from 'astro:env/client';
import { CACHE_TTL, LOG_LEVEL, STRIPE_SECRET_KEY } from 'astro:env/server';
Astro.response.headers.set('Cache-Control', `max-age=${CACHE_TTL}`);
---
<p>API: {PUBLIC_API_URL}</p>
<p>Analytics: {typeof ANALYTICS_ENABLED} {String(ANALYTICS_ENABLED)}</p>
<p>TTL: {typeof CACHE_TTL} {CACHE_TTL}</p>
<p>Log level: {LOG_LEVEL}</p>
<p>Stripe key set: {String(STRIPE_SECRET_KEY.startsWith('sk_'))}</p>
<script>
import { PUBLIC_API_URL, ANALYTICS_ENABLED } from 'astro:env/client';
console.log('client sees', PUBLIC_API_URL, ANALYTICS_ENABLED);
</script>
Built with the Node adapter and started with the secret in the environment, the page rendered:
cache-control: max-age=300
<p>API: https://api.example.com</p>
<p>Analytics: boolean false</p>
<p>TTL: number 300</p>
<p>Log level: info</p>
<p>Stripe key set: true</p>
ANALYTICS_ENABLED arrived as a real boolean and CACHE_TTL as a number, unlike import.meta.env, where both would be strings. In the client script, the built output contained "https://api.example.com" and !1 (minified false), so client variables are inlined just like PUBLIC_ variables.
Generated Types
Astro writes type declarations to .astro/env.d.ts whenever you run astro dev, astro build or astro sync. For the schema above:
declare module "astro:env/client" {
export const PUBLIC_API_URL: string;
export const ANALYTICS_ENABLED: boolean;
}
declare module "astro:env/server" {
export const CACHE_TTL: number;
export const LOG_LEVEL: "debug" | "info" | "warn" | "error";
export const STRIPE_SECRET_KEY: string;
}
That means type mistakes are caught by your editor and astro check. Assigning CACHE_TTL to a string, or LOG_LEVEL to the single literal 'info', produced:
src/pages/typed.astro:5:7 - error ts(2322): Type 'number' is not assignable to type 'string'.
src/pages/typed.astro:6:7 - error ts(2322): Type '"debug" | "info" | "warn" | "error"' is not assignable to type '"info"'.
If you previously declared variables by hand in src/env.d.ts under ImportMetaEnv, you can remove those declarations for anything that's now in the schema.
Data Types and Validation Options
Each envField function accepts context, access, optional and default, plus type-specific checks. The extra options map to Zod validators under the hood.
envField.string():min,maxandlengthfor length checks,url: truefor a valid URL, andincludes,startsWithandendsWithfor content checks.envField.number():gt,lt,minandmaxfor ranges, andint: truefor whole numbers.envField.boolean(): acceptstrueorfalse.envField.enum(): requires avaluesarray and returns a union type of those strings.
Validation errors are collected and reported together, which saves a lot of back and forth. Building with PUBLIC_API_URL=not-a-url and CACHE_TTL=1.5, and no Stripe key, with validateSecrets enabled (explained below), gave one error listing all three problems:
[EnvInvalidVariables] The following environment variables defined in `env.schema` are invalid:
- The following constraints for PUBLIC_API_URL are not met: url
- The following constraints for CACHE_TTL are not met: int
- STRIPE_SECRET_KEY is missing
An enum mismatch reads like this:
- LOG_LEVEL's type is invalid, expected: 'debug' | 'info' | 'warn' | 'error'
When Validation Happens
This is the part that surprises people, so it's worth being precise.
Public Variables Are Checked at Build Time
Public client and public server variables are validated when you start astro dev or run astro build, and their values are written into the build. The consequence for public server variables: they're fixed at build time, like import.meta.env. Starting the built server with CACHE_TTL=120 still rendered TTL: number 300, the value from the build. If a value must be changeable at runtime, make it a secret, even if it isn't sensitive.
Secrets Are Checked at Runtime by Default
Secrets aren't validated during the build unless you ask. They're read and checked on the server when astro:env/server is first imported. Starting the server without STRIPE_SECRET_KEY let it boot normally, but the first request to the page returned HTTP 500 with:
[ERROR] EnvInvalidVariables: The following environment variables defined in `env.schema` are invalid:
- STRIPE_SECRET_KEY is missing
Note that all secrets are validated whenever anything is imported from astro:env/server. The page only needed CACHE_TTL to trigger the check of STRIPE_SECRET_KEY too.
Prerendered Pages Validate Secrets During the Build
A prerendered page runs during the build, so if it imports from astro:env/server, the secret check runs then. A static page that only imported CACHE_TTL failed the build with STRIPE_SECRET_KEY is missing. The Astro docs suggest passing dummy values during the build in this situation, for example in CI:
STRIPE_SECRET_KEY=sk_dummy npx astro build
validateSecrets: Fail Early in CI
If you'd rather catch missing secrets before deploying, turn on validateSecrets:
// astro.config.mjs
import { defineConfig, envField } from "astro/config";
export default defineConfig({
env: {
validateSecrets: true,
schema: {
// ...
},
},
});
Now astro dev and astro build check secrets on start as well. That's useful when your build environment has the same secrets as production. It also checks constraints: STRIPE_SECRET_KEY=pk_wrong failed with The following constraints for STRIPE_SECRET_KEY are not met: startsWith. The secrets are still read at runtime; this only adds an early check.
Reading Secrets Dynamically With getSecret()
astro:env/server also exports getSecret(), which returns the raw string value of any variable by name, or undefined:
---
// src/pages/debug.astro
export const prerender = false;
import { getSecret } from 'astro:env/server';
const rawTtl = getSecret('CACHE_TTL');
const buildId = getSecret('BUILD_ID');
---
<p>Raw TTL: {typeof rawTtl} {rawTtl}</p>
<p>Not in schema: {buildId ?? 'undefined'}</p>
Started with CACHE_TTL=120 BUILD_ID=42, it rendered:
<p>Raw TTL: string 120</p>
<p>Not in schema: 42</p>
Two things stand out. getSecret() returns strings, without schema conversion. And it reads the runtime environment, so it saw 120 even though the typed CACHE_TTL import still returned the build-time 300. Use it for variables whose names you only know at runtime, or that aren't in your schema. The Astro docs recommend it over process.env because the adapter provides its implementation, so the same code works on Node, Vercel, Netlify and Cloudflare.
astro:env and Adapters
Secrets come from wherever your adapter reads the environment.
- Node: secrets come from
process.env. The adapter doesn't load.envfiles at runtime, so pass them to the process, for example withdocker run --env-file(see deploying with Docker). - Vercel and Netlify: set them in the project's environment variable settings.
- Cloudflare: secrets come from the Worker's environment. A schema with
GREETINGandAPI_TOKENas server secrets readvarsfromwrangler.jsoncand.dev.varsduringastro preview, and renderedHello from wrangler.jsonc / local-dev-token. In production, set secrets withwrangler secret put.
Because the import is the same everywhere, astro:env is the easiest way to keep configuration code portable between hosts.
Limitations
- Only inside Astro's build:
astro:envis a virtual module, so it works in components, pages, endpoints, middleware, framework components and other modules processed by Astro. It doesn't work inastro.config.mjsor in standalone Node scripts; useprocess.envthere. - Server module stays on the server: importing
astro:env/serverin a client<script>fails the build with[ServerOnlyModule] The "astro:env/server" module is only available server-side. - Four data types only: string, number, boolean and enum. For JSON or lists, store a string and parse it yourself.
- Public values are build-time values: change them, rebuild.
FAQ: Type-Safe Environment Variables With astro:env
No. With astro:env, the context and access options decide whether a variable reaches the browser, not its name. Keeping the prefix on client variables is still a good habit because it makes them easy to recognise.
Secrets are validated at runtime by default, the first time astro:env/server is imported on the server. Set validateSecrets to true in the env config to check them when the dev server starts or the build runs.
Either validateSecrets is enabled, or a prerendered page imports from astro:env/server, which validates all secrets during the build. Provide the secret, or a dummy value, in the build environment.
No. Public client and public server variables are validated and inlined at build time. If a value needs to change at runtime, define it with access set to secret, or read it with getSecret.
import.meta.env gives you untyped strings inlined at build time, with no validation. astro:env adds a schema, converts numbers and booleans, reports missing or invalid values, generates TypeScript types, and keeps secrets out of your built code.
Yes. With the Cloudflare adapter, secrets are read from the Worker environment, including vars in your Wrangler config, secrets set with wrangler secret put, and values in .dev.vars during local development.
Conclusion
astro:env turns environment variables from loose strings into a typed, validated part of your project. Describe each variable once in env.schema with envField, pick its context and access level, and import it from astro:env/client or astro:env/server. You get real numbers, booleans and enums, editor autocompletion, and a single clear error when something is missing.
The key thing to remember is timing. Public variables are checked and inlined at build time, while secrets are read and validated on the server at runtime, unless you enable validateSecrets or a prerendered page imports them. Put sensitive and runtime-changeable values in secrets, use getSecret() for anything dynamic, and your configuration will behave the same across every adapter.


