Env validation
Every env var across all three packages is zod-validated at boot: required vs add-on env, adding your own, and which destination each var lives in.
GlassKit uses the T3 stack pattern for env vars: every key validated at module load with zod, fail-fast on missing or malformed values, server vs client enforced by a Proxy at access time.
Three env.ts files, one per package:
| File | Package | Bootstrap-imported from |
|---|---|---|
companion/env.ts | @t3-oss/env-nextjs | companion/next.config.ts |
app/src/env.ts | @t3-oss/env-core | app/vite.config.ts |
packages/backend/convex/env.ts | @t3-oss/env-core | every Convex function module that imports env |
A missing or malformed key fails next dev / next build /
vite dev / vite build with a formatted error listing the
exact key and zod issue, with no runtime crash ten frames into the
Clerk SDK.
Where env vars live
Three workspaces, three deploy targets, three env locations. Each var lives in exactly one of these (with two exceptions noted below):
| Location | Local dev | Production |
|---|---|---|
| Convex backend | Convex dashboard → your dev deployment → Settings → Environment Variables (the local .env file is NOT used by Convex; functions run in Convex's cloud). | Convex dashboard → your prod deployment → same place. |
| Companion (Next.js) | companion/.env.local | Vercel → companion project → Settings → Environment Variables |
| Glasses app (Vite) | app/.env.local | Vercel → glasses-app project → Settings → Environment Variables |
The rule of thumb:
- Anything starting with
CLERK_JWT_…,CLERK_WEBHOOK_…,STRIPE_SECRET_…,STRIPE_WEBHOOK_…,RESEND_API_KEY,RESEND_WEBHOOK_…,ANTHROPIC_API_KEY,OPENAI_API_KEY,EMAIL_FROM→ Convex backend only. These are read by Convex functions running in Convex's cloud. - Anything starting with
NEXT_PUBLIC_…or unprefixed server-side Next.js vars (CLERK_SECRET_KEY,GLASSES_APP_URL,GLASSES_APP_NAME) → Companion only. - Anything starting with
VITE_…→ Glasses app only. CONVEX_DEPLOY_KEY→ Both Vercel projects (companion + glasses app), prod-only. Both projects'vercel-buildscript invokesconvex deploy, which needs the key. Local dev doesn't need it:pnpm exec convex devuses your CLI auth.
The two exceptions, vars that appear in two locations:
CLERK_SECRET_KEYlives in both the Convex dashboard (sopairing.tscan mint Sign-In Tokens) and the companion env (so Clerk's Next.js middleware verifies the session). Same value, set twice.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY+VITE_CLERK_PUBLISHABLE_KEYare two different var names sharing the same value: Next.js and Vite have different prefix conventions, but the underlying Clerk key is identical. Same forNEXT_PUBLIC_CONVEX_URL+VITE_CONVEX_URL.
Required by default + add-on env
The base boilerplate's required env set is small: Clerk + Convex
- the glasses-app display name. Everything else (Stripe, Resend,
AI) only exists in
env.tswhen its add-on is enabled.
| Var | Lives in | Required for |
|---|---|---|
CLERK_JWT_ISSUER_DOMAIN | Convex backend | Always |
CLERK_SECRET_KEY | Convex backend + Companion | Always |
CLERK_WEBHOOK_SECRET | Convex backend | Always |
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY | Companion | Always |
VITE_CLERK_PUBLISHABLE_KEY | Glasses app | Always (same value as the NEXT_PUBLIC_ one) |
NEXT_PUBLIC_CONVEX_URL | Companion | Always |
VITE_CONVEX_URL | Glasses app | Always (same value as the NEXT_PUBLIC_ one) |
CONVEX_DEPLOY_KEY | Vercel companion + Vercel glasses-app projects | Prod-only, used by vercel-build |
GLASSES_APP_URL | Companion | Only post-deploy. Auto-resolved via Connected Projects if you link the projects in Vercel (see Deploying). |
GLASSES_APP_NAME | Companion | Optional (default GlassKit) |
STRIPE_* (SECRET_KEY, WEBHOOK_SECRET, PRICE_*) | Convex backend | Payments add-on. |
NEXT_PUBLIC_STRIPE_PRICE_* | Companion | Payments add-on. |
RESEND_API_KEY / RESEND_WEBHOOK_SECRET / EMAIL_FROM | Convex backend | Email add-on. |
AI_PROVIDER / ANTHROPIC_API_KEY / OPENAI_API_KEY / AI_MODEL | Convex backend | AI Showcase add-on. |
Fail-early by design
Every var here is required when its enabling add-on is on. Missing vars fail the build / deploy with a formatted error listing the exact key and validation rule. The add-on opt-in model is what makes this tractable: only the env you've explicitly asked for is enforced.
Adding your own env var
Same pattern in all three. Three steps.
Add to the zod schema
The schema is split server/client in companion/env.ts (because
Next.js bundles client code separately and needs to know what's
safe to inline). The Vite app and Convex backend have a single
section (no bundler split inside those packages).
export const env = createEnv({
server: {
MY_SECRET: z.string().startsWith("ms_", "Must start with ms_."),
// ...
},
client: {
NEXT_PUBLIC_MY_FLAG: z.enum(["on", "off"]).default("off"),
// ...
},
runtimeEnv: {
MY_SECRET: process.env.MY_SECRET,
NEXT_PUBLIC_MY_FLAG: process.env.NEXT_PUBLIC_MY_FLAG,
// ...
},
emptyStringAsUndefined: true,
});export const env = createEnv({
clientPrefix: "VITE_",
client: {
VITE_MY_FLAG: z.enum(["on", "off"]).default("off"),
},
runtimeEnv: runtimeSource, // see "Adaptive runtime" below
emptyStringAsUndefined: true,
});export const env = createEnv({
server: {
MY_SECRET: z.string().startsWith("ms_"),
},
runtimeEnv: process.env,
emptyStringAsUndefined: true,
});Add to .env.example
Each package has one. They're not for distribution of values;
they're the documented set of keys + format. Match the schema
exactly; the .env.example is the canonical reference for what an
AI agent (or you) needs to collect during first-time setup.
Consume via env.X, not process.env.X
import { env } from "@/env"; // companion
import { env } from "./env"; // app
import { env } from "./env"; // backend
const url = new MyService({ apiKey: env.MY_SECRET });You'll get autocomplete, type narrowing on .enum() values, and a
runtime Proxy guard that throws if a server-only var is accessed
from a client component (Next.js companion only).
Zod rules that pull weight
Beyond z.string() and z.enum():
z.string().url(): must be a fully-qualified URL. The validator's error message tells you exactly what's wrong (Invalid url).z.string().startsWith("sk_"): Clerk secret keys, Stripe secret keys, Resend API keys, etc. Catches the "you pasted the pk_ instead of the sk_" footgun before the SDK call.z.string().default("…"): for keys that have sensible defaults (EMAIL_FROM,APP_URL,AI_PROVIDER). You don't have to set them; the default ships.z.string().optional(): for genuinely optional keys (GLASSES_APP_URLis undefined in local dev, set in production). The companion's dashboard reads it asenv.GLASSES_APP_URL?.trim() || nulland renders a different UI for each case.
The server / client split (companion only)
Next.js does static string replacement at build time for any
process.env.NEXT_PUBLIC_* reference. @t3-oss/env-nextjs
enforces:
- Anything in the
server:schema is never bundled to the client. A client component that importsenvand reads a server-only key throws at access time via the Proxy guard. - Anything in the
client:schema must start withNEXT_PUBLIC_. The wrapper enforces this at the type level too: aclient:entry namedMY_KEY(without the prefix) is a TypeScript error. - The
runtimeEnvmap must list every server + client key explicitly (Next.js's static-replacement boundary; we can't useprocess.envas a catch-all).
Result
It's structurally impossible to ship CLERK_SECRET_KEY to the
browser.
The Vite app and Convex backend don't have this split: every key
in their schema is "server-side" in the sense that Vite inlines
all VITE_* vars into the client bundle at build time, and the
Convex runtime IS the server. So they have just a single schema
section.
The adaptive runtime in app/src/env.ts
Vite's import.meta.env is injected only in the client bundle.
When vite.config.ts bootstrap-imports env.ts at config-load
time, the file runs in Node, where import.meta.env is
undefined. Reading from import.meta.env directly would crash
with Cannot read properties of undefined.
The fix is a one-liner at the top of app/src/env.ts:
const runtimeSource: Record<string, string | undefined> =
(typeof import.meta !== "undefined" && import.meta.env) ||
(process.env as Record<string, string | undefined>);In the Vite client bundle, import.meta.env exists → it wins. In
Node (config-load, tests), it's undefined → process.env fallback.
You won't usually touch this. Just don't replace runtimeSource
with import.meta.env directly.
What your AI agent handles during first-time setup
The walkthrough in the repo's root AGENTS.md tells the AI to
collect Clerk + Convex credentials, then:
- write
companion/.env.local(NEXT_PUBLIC_CONVEX_URL + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY + CLERK_SECRET_KEY + GLASSES_APP_NAME) - write
app/.env.local(VITE_CONVEX_URL + VITE_CLERK_PUBLISHABLE_KEY) - push to the Convex dashboard via
pnpm exec convex env set …(CLERK_JWT_ISSUER_DOMAIN, CLERK_SECRET_KEY, CLERK_WEBHOOK_SECRET) - walk you through registering the Clerk user-lifecycle webhook and collect its signing secret
Add-ons toggle deterministically via the addon CLI:
pnpm run addon enable payments # or `email`, `ai`That single command flips the add-on's .ts.disabled files back
to active extensions and inserts content into the
glasskit:add-on:<id>:<marker>:BEGIN/END fenced regions in
shared files (convex.config.ts, http.ts, env.ts, etc.). The
add-on's env vars (Stripe / Resend / Anthropic keys) you then
push to Convex the same way:
pnpm exec convex env set <KEY> <value> per key. The CLI's
output lists the exact key names required.
The add-on toggle implementation lives in scripts/addon.ts +
scripts/add-ons/. Open it if you want the exact files renamed
and fence-content templates inserted per add-on.
Reading env from outside React
Server-side code (Next.js route handlers, server actions, Convex
functions) imports env the same way:
import { env } from "@/env";
// env.MY_SECRET is fully typed + runtime-validatedimport { env } from "./env";In Convex functions, env reads from Convex's runtime environment
(process.env populated from your Convex dashboard env). Local
.env.local files don't apply to Convex functions; they run in
Convex's cloud, not your machine.
Anti-pattern: don't bypass via raw process.env
Two failure modes
- Production crash. You typo'd the key name and the runtime
reads
undefined. The downstream library crashes with an opaque error six frames in. - Silent secret leak. You read a server-only key inside a
React component that ends up bundled to the client. Next.js's
static replacer inlines
undefined(since the var isn't whitelisted as NEXT_PUBLIC_); the wrapper would have caught this.
Always go through env.X. The schema is the contract.