Type something to search...
How to use astro:env for type-safe environment variables?

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:

KindcontextaccessImport fromWhere the value ends up
Public client"client""public"astro:env/clientClient and server bundles
Public server"server""public"astro:env/serverServer bundle
Secret server"server""secret"astro:env/serverNowhere; 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, max and length for length checks, url: true for a valid URL, and includes, startsWith and endsWith for content checks.
  • envField.number(): gt, lt, min and max for ranges, and int: true for whole numbers.
  • envField.boolean(): accepts true or false.
  • envField.enum(): requires a values array 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 .env files at runtime, so pass them to the process, for example with docker 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 GREETING and API_TOKEN as server secrets read vars from wrangler.jsonc and .dev.vars during astro preview, and rendered Hello from wrangler.jsonc / local-dev-token. In production, set secrets with wrangler 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:env is a virtual module, so it works in components, pages, endpoints, middleware, framework components and other modules processed by Astro. It doesn't work in astro.config.mjs or in standalone Node scripts; use process.env there.
  • Server module stays on the server: importing astro:env/server in 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.

Tags :
Share :

Related Posts

What are Astro Actions and how do they work?

What are Astro Actions and how do they work?

Astro Actions are type-safe backend functions that you define once on the server and then call from the browser as if they were ordinary async fu

Dive Deeper
What are Astro adapters and which one should you choose?

What are Astro adapters and which one should you choose?

An Astro adapter is an integration that builds the server part of your project for a specific runtime, such as Node.js, Vercel Functions, Netlify

Dive Deeper
How to build API endpoints in Astro?

How to build API endpoints in Astro?

To build an API endpoint in Astro, create a .js or .ts file in src/pages/ and export a function named after an HTTP method, such as GET or

Dive Deeper