ADR-050: Type-Safe Environment via astro:env
Adopts astro:env (env.schema in astro.config) for the PUBLIC_* surface and reduces the hand-rolled validate-env.ts to the one check astro:env can't make.
Last updated
Status
Accepted
Context
Environment access was hand-rolled. scripts/src/validate-env.ts validated only
SITE_URL (presence, placeholder content, URL shape), and the PUBLIC_* surface
(contact details, social links) was read in components via untyped
import.meta.env.PUBLIC_* || "fallback" literals scattered across
src/pages/contact.astro.
Astro ships astro:env (stable since Astro 5.0): a schema declared in
astro.config gives type-safe, validated access to env vars via
astro:env/client and astro:env/server. It replaces both the untyped reads and
most of the custom validator — but not the placeholder-content heuristic.
Decision
Adopt astro:env for the PUBLIC_* surface; keep a slim placeholder guard.
1. Schema in astro.config
env.schema declares the eight consumed PUBLIC_* vars (five contact, three
social) as envField.string({ context: "client", access: "public", optional: true, default: … }). The defaults are the demo values, so consumers import the
value directly — the scattered || "fallback" literals are gone, and the demo
fallbacks now live in exactly one place. (Amended 2026-07-05: originally nine —
PUBLIC_CONTACT_CHAT_HOURS was removed along with the contact page’s fake
“Live Chat — Online now” card, which advertised a chat integration the template
does not ship.)
2. Typed consumption
src/pages/contact.astro imports the vars from astro:env/client instead of
reading import.meta.env. Types and defaults flow from the schema.
3. What stays on process.env (deliberately)
SITE_URL / PUBLIC_SITE_URL and DEPLOY_TARGET are read at config-load
time in astro.config.mjs (to compute site and base) — before astro:env
exists. They cannot use astro:env and are intentionally not in the schema.
import.meta.env.PROD and import.meta.env.BASE_URL are Astro built-ins, not
custom env, and are left as-is.
4. Slim placeholder guard retained
astro:env validates presence and type, not whether a real value replaced a
template placeholder. scripts/src/validate-env.ts is reduced from a full
validator to a single exported, unit-tested isPlaceholderUrl() plus a thin CLI
that rejects example.com / your-username / your-domain / localhost in
SITE_URL. It stays wired as the env:validate prebuild step. This is the one
cloner safety net astro:env can’t provide. (Considered and rejected: dropping
it entirely — a directory submission will have a real URL, but the guard is ~10
lines and protects every cloner, not just the demo.)
Consequences
- Positive: compile-time safety on the
PUBLIC_*surface; demo defaults centralised in the schema; the custom validator shrinks to its irreducible core (now unit-tested); no untypedimport.meta.envreads remain in app code. - Negative: the
site/baseplumbing still readsprocess.envin the config file — an unavoidable split, documented above so it doesn’t read as an oversight. - For cloners: set the
PUBLIC_*vars in.env; missing ones fall back to the schema defaults. A placeholderSITE_URLfails the build early.
References
- ADR-035: Template Scope Boundary
astro.config.mjs(env.schema),src/pages/contact.astro,scripts/src/validate-env.ts- Astro environment variables / astro:env
Date: 2026-06-07
Participants: Template maintainers
Outcome: Accepted — astro:env adopted for PUBLIC_*; validate-env.ts slimmed to the placeholder guard