GlassKit

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:

FilePackageBootstrap-imported from
companion/env.ts@t3-oss/env-nextjscompanion/next.config.ts
app/src/env.ts@t3-oss/env-coreapp/vite.config.ts
packages/backend/convex/env.ts@t3-oss/env-coreevery 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):

LocationLocal devProduction
Convex backendConvex 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.localVercel → companion project → Settings → Environment Variables
Glasses app (Vite)app/.env.localVercel → 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_FROMConvex 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_KEYBoth Vercel projects (companion + glasses app), prod-only. Both projects' vercel-build script invokes convex deploy, which needs the key. Local dev doesn't need it: pnpm exec convex dev uses your CLI auth.

The two exceptions, vars that appear in two locations:

  • CLERK_SECRET_KEY lives in both the Convex dashboard (so pairing.ts can 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_KEY are two different var names sharing the same value: Next.js and Vite have different prefix conventions, but the underlying Clerk key is identical. Same for NEXT_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.ts when its add-on is enabled.
VarLives inRequired for
CLERK_JWT_ISSUER_DOMAINConvex backendAlways
CLERK_SECRET_KEYConvex backend + CompanionAlways
CLERK_WEBHOOK_SECRETConvex backendAlways
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEYCompanionAlways
VITE_CLERK_PUBLISHABLE_KEYGlasses appAlways (same value as the NEXT_PUBLIC_ one)
NEXT_PUBLIC_CONVEX_URLCompanionAlways
VITE_CONVEX_URLGlasses appAlways (same value as the NEXT_PUBLIC_ one)
CONVEX_DEPLOY_KEYVercel companion + Vercel glasses-app projectsProd-only, used by vercel-build
GLASSES_APP_URLCompanionOnly post-deploy. Auto-resolved via Connected Projects if you link the projects in Vercel (see Deploying).
GLASSES_APP_NAMECompanionOptional (default GlassKit)
STRIPE_* (SECRET_KEY, WEBHOOK_SECRET, PRICE_*)Convex backendPayments add-on.
NEXT_PUBLIC_STRIPE_PRICE_*CompanionPayments add-on.
RESEND_API_KEY / RESEND_WEBHOOK_SECRET / EMAIL_FROMConvex backendEmail add-on.
AI_PROVIDER / ANTHROPIC_API_KEY / OPENAI_API_KEY / AI_MODELConvex backendAI 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).

companion/env.ts
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,
});
app/src/env.ts
export const env = createEnv({
  clientPrefix: "VITE_",
  client: {
    VITE_MY_FLAG: z.enum(["on", "off"]).default("off"),
  },
  runtimeEnv: runtimeSource,    // see "Adaptive runtime" below
  emptyStringAsUndefined: true,
});
packages/backend/convex/env.ts
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_URL is undefined in local dev, set in production). The companion's dashboard reads it as env.GLASSES_APP_URL?.trim() || null and 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 imports env and reads a server-only key throws at access time via the Proxy guard.
  • Anything in the client: schema must start with NEXT_PUBLIC_. The wrapper enforces this at the type level too: a client: entry named MY_KEY (without the prefix) is a TypeScript error.
  • The runtimeEnv map must list every server + client key explicitly (Next.js's static-replacement boundary; we can't use process.env as 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:

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:

companion/app/api/something/route.ts
import { env } from "@/env";
// env.MY_SECRET is fully typed + runtime-validated
packages/backend/convex/something.ts
import { 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.

On this page