| name | t3-env |
| description | `@t3-oss/env-core` for Snapmatch — build-time validation for public `VITE_*` configuration, including an enabled/disabled Firebase client contract, with an intentionally empty server slot. |
| license | MIT |
Owns @t3-oss/env-core setup in apps/<app>/app/env.ts. Schemas are authored per the zod
skill; t3-env owns the createEnv shape, the client / empty server split, the VITE_ prefix,
the Firebase enabled discriminated validation, and the rule that validation runs at build time —
never only at first page load.
When to invoke
- Authoring or editing
apps/<app>/app/env.ts.
- Adding a new
VITE_* variable: schema entry + build-env injection + dev .env line.
- Diagnosing "env var is undefined in production but works in dev" (usually missing build-env
injection or a missing committed public default).
- Configuring Firebase Web SDK identifiers or switching the Firebase client on/off.
- A request to put Firebase Admin or service-account credentials in a web env file — refuse and
route deploy credentials to the CI/CLI secret store instead.
Owns
@t3-oss/env-core setup with client + empty server slots, VITE_* validation at build time, the
typed env import, and the public Firebase Web configuration boundary.
Defers to
zod — for how to author a schema (z.url(), z.coerce.number<string>(), z.stringbool(), refinements). t3-env owns the createEnv wiring; zod owns the schema.
nitro — for the static artifact, canonical Firebase Hosting root path, and optional Pages mirror.
turborepo — for when env validation runs in bun run build (it runs once when vite.config.ts is loaded; turbo caches accordingly).
Snapmatch stack rules
- Pillar 2 (Zod-first types) means: every env entry is a Zod schema; the
env import is typed from z.infer shapes via createEnv's inference. No hand-written Env interface.
- Pillar 4 (CLI-gate-first) means: env validation runs in
bun run build (because vite.config.ts does import "./app/env" as a side effect) and must fail the build before the deploy artifact uploads. Never gate it behind first-page-load.
- Firebase Hosting is static-only — the
server slot stays {}. Anything in client ends up in
the published JS bundle and is publicly visible.
- Firebase Web config (
apiKey, auth domain, project ID, storage bucket, messaging sender ID, app
ID, and optional measurement ID) is public project-identification metadata. Validate it, but do
not mislabel it as a server secret.
VITE_FIREBASE_ENABLED=false permits a generic generated app to build without project-specific
Firebase identifiers. When enabled, a second Zod discriminated-union parse makes every required
identifier mandatory.
VITE_FIREBASE_USE_EMULATORS is a public boolean switch, not a credential. Keep it false in the
canonical Hosting build and permit it in both branches of the Firebase discriminated union.
VITE_FIREBASE_DOMAIN_SYNC_ENABLED is an independent public rollout switch. It must be exactly
false when Firebase itself is disabled, defaults to false in generated and production apps,
and may be true only for the demo-project emulator until the production checkpoint is approved.
- An emulator-enabled configuration must use a
demo-* project ID. Reject any other project at the
Zod boundary so a missing emulator cannot fall through to production.
- Firebase Admin private keys, service-account JSON, refresh tokens, and
GOOGLE_APPLICATION_CREDENTIALS never enter client, .env, .env.local, or the browser bundle.
The Admin SDK is not part of this static client.
- The
server slot remains in the file even when empty so a future server runtime can be introduced
deliberately without changing the client contract. Its presence does not imply a backend exists.
Patterns
Canonical apps/<app>/app/env.ts
import { createEnv } from "@t3-oss/env-core";
import * as z from "zod";
const runtimeEnv: Record<string, string | undefined> = {
...(typeof process !== "undefined" ? process.env : {}),
...(typeof import.meta !== "undefined" ? import.meta.env : {}),
};
export const env = createEnv({
clientPrefix: "VITE_",
client: {
VITE_GAME_TITLE: z.string().min(1),
VITE_FIREBASE_ENABLED: z.stringbool().default(false),
VITE_FIREBASE_USE_EMULATORS: z.stringbool().default(false),
VITE_FIREBASE_DOMAIN_SYNC_ENABLED: z.stringbool().default(false),
VITE_FIREBASE_API_KEY: z.string().startsWith("AIza").optional(),
VITE_FIREBASE_AUTH_DOMAIN: z.string().min(1).optional(),
VITE_FIREBASE_PROJECT_ID: z
.string()
.regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/)
.optional(),
VITE_FIREBASE_STORAGE_BUCKET: z.string().min(1).optional(),
VITE_FIREBASE_MESSAGING_SENDER_ID: z.string().regex(/^\d+$/).optional(),
VITE_FIREBASE_APP_ID: z.string().min(1).optional(),
VITE_FIREBASE_MEASUREMENT_ID: z.string().regex(/^G-[A-Z0-9]+$/).optional(),
},
server: {},
runtimeEnv,
emptyStringAsUndefined: true,
});
const FirebaseEnvironmentSchema = z
.discriminatedUnion("enabled", [
z.object({
enabled: z.literal(false),
useEmulators: z.boolean(),
domainSyncEnabled: z.literal(false),
}),
z.object({
enabled: z.literal(true),
useEmulators: z.boolean(),
domainSyncEnabled: z.boolean(),
apiKey: z.string().startsWith("AIza"),
authDomain: z.string().min(1),
projectId: z.string().regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/),
storageBucket: z.string().min(1),
messagingSenderId: z.string().regex(/^\d+$/),
appId: z.string().min(1),
measurementId: z.string().regex(/^G-[A-Z0-9]+$/).optional(),
}),
])
.superRefine((value, context) => {
if (value.enabled && value.useEmulators && !value.projectId.startsWith("demo-")) {
context.addIssue({
code: "custom",
message: "Firebase emulators require a demo-* project ID",
path: ["projectId"],
});
}
});
export const firebaseEnv = FirebaseEnvironmentSchema.parse({
enabled: env.VITE_FIREBASE_ENABLED,
useEmulators: env.VITE_FIREBASE_USE_EMULATORS,
domainSyncEnabled: env.VITE_FIREBASE_DOMAIN_SYNC_ENABLED,
apiKey: env.VITE_FIREBASE_API_KEY,
authDomain: env.VITE_FIREBASE_AUTH_DOMAIN,
projectId: env.VITE_FIREBASE_PROJECT_ID,
storageBucket: env.VITE_FIREBASE_STORAGE_BUCKET,
messagingSenderId: env.VITE_FIREBASE_MESSAGING_SENDER_ID,
appId: env.VITE_FIREBASE_APP_ID,
measurementId: env.VITE_FIREBASE_MEASUREMENT_ID,
});
clientPrefix: "VITE_" — Vite only inlines variables with this prefix.
emptyStringAsUndefined: true — required for new code; an unset build value renders as "" and
would otherwise pass z.string().
- The discriminated parse fails the build when Firebase is enabled but any required public identifier
is absent. Disabled templates do not need dummy values.
runtimeEnv must merge process.env AND import.meta.env
Vite's config-load subprocess does NOT inherit Bun's auto-loaded .env and import.meta.env is empty during config evaluation. Browser runtime is the inverse: process.env is unavailable, import.meta.env is statically replaced. Spread both so the same env works in both contexts:
const runtimeEnv: Record<string, string | undefined> = {
...(typeof process !== "undefined" ? process.env : {}),
...(typeof import.meta !== "undefined" ? import.meta.env : {}),
};
export const env = createEnv({ clientPrefix: "VITE_", client: { ... }, server: {}, runtimeEnv, emptyStringAsUndefined: true });
Env load and import in vite.config.ts (the build-time gate)
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import { defineConfig, loadEnv } from "vite";
export default defineConfig(async ({ mode }) => {
const fileEnv = loadEnv(mode, process.cwd(), "");
for (const [key, value] of Object.entries(fileEnv)) {
if (process.env[key] === undefined) process.env[key] = value;
}
await import("./app/env");
return {
base: process.env.BASE_PATH ?? "/",
plugins: [tanstackStart({ })],
};
});
Loading the file env before the dynamic import is load-bearing: the Vite config subprocess does not
receive Bun's automatic .env loading. Existing process.env values win so CI can override local
defaults. Vite evaluates vite.config.ts once per build; a thrown ZodError
aborts before apps/<app>/dist/client can be deployed. The canonical Firebase Hosting build sets
BASE_PATH=/; an optional GitHub Pages mirror may inject its project base path.
Consuming env in app code
import { initializeApp } from "firebase/app";
import { firebaseEnv } from "~/env";
if (firebaseEnv.enabled) {
initializeApp({
apiKey: firebaseEnv.apiKey,
authDomain: firebaseEnv.authDomain,
projectId: firebaseEnv.projectId,
storageBucket: firebaseEnv.storageBucket,
messagingSenderId: firebaseEnv.messagingSenderId,
appId: firebaseEnv.appId,
measurementId: firebaseEnv.measurementId,
});
}
if (firebaseEnv.enabled && firebaseEnv.domainSyncEnabled) {
}
The discriminant narrows every required field to string. No cast, non-null assertion, or direct
process.env access is needed in application code.
CI build injection
jobs:
build:
runs-on: ubuntu-latest
env:
BASE_PATH: /
VITE_GAME_TITLE: ${{ vars.VITE_GAME_TITLE }}
VITE_FIREBASE_ENABLED: "true"
VITE_FIREBASE_USE_EMULATORS: "false"
VITE_FIREBASE_DOMAIN_SYNC_ENABLED: "false"
VITE_FIREBASE_API_KEY: ${{ vars.VITE_FIREBASE_API_KEY }}
VITE_FIREBASE_AUTH_DOMAIN: ${{ vars.VITE_FIREBASE_AUTH_DOMAIN }}
VITE_FIREBASE_PROJECT_ID: ${{ vars.VITE_FIREBASE_PROJECT_ID }}
VITE_FIREBASE_STORAGE_BUCKET: ${{ vars.VITE_FIREBASE_STORAGE_BUCKET }}
VITE_FIREBASE_MESSAGING_SENDER_ID: ${{ vars.VITE_FIREBASE_MESSAGING_SENDER_ID }}
VITE_FIREBASE_APP_ID: ${{ vars.VITE_FIREBASE_APP_ID }}
VITE_FIREBASE_MEASUREMENT_ID: ${{ vars.VITE_FIREBASE_MEASUREMENT_ID }}
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- run: bun run build
These values are public after bundling, so repository variables or committed public app defaults
are appropriate. A Firebase deploy service account, if CI uses one, is a separate secret consumed by
the CLI action and must never be copied into VITE_*.
Adding a new variable — four places to update
apps/<app>/app/env.ts and turbo/generators/templates/app/app/env.ts — add the schema.
- The selected app's committed public
.env and the template's disabled .env.example contract;
keep machine overrides in gitignored .env.local.
- CI build
env: and Turbo's declared VITE_* inputs when CI supplies the value.
- README and this skill when the variable changes the public Firebase contract.
Anti-patterns
- Don't add browser Firebase configuration to
server — Firebase Hosting serves a static bundle;
the Web SDK reads public VITE_* values from client.
- Don't import
@t3-oss/env-nextjs — it ships NEXT_PUBLIC_ prefix logic and runtime assumptions that are wrong for a static Vite bundle.
- Don't omit
emptyStringAsUndefined: true — without it, an unset build value silently becomes
"" and slips past z.string().
- Don't validate only at first page load — by then the broken bundle is already deployable. The
dynamic
import("./app/env") from vite.config.ts is the build gate.
- Don't put Firebase Admin credentials, service-account JSON, refresh tokens, or any private key in
a
client.VITE_* field — every value is shipped in the JS bundle and publicly visible.
- Don't call an optional-field Firebase config validated just because each field is optional —
parse the enabled/disabled discriminated union so partial enabled configurations fail the build.
- Don't couple Firebase initialization to domain rollout — the sentinel/Analytics boundary may be
enabled while
VITE_FIREBASE_DOMAIN_SYNC_ENABLED=false keeps Auth listeners and writes stopped.
- Don't use a production project ID with
VITE_FIREBASE_USE_EMULATORS=true — require demo-* so
an unavailable emulator cannot become an accidental production write path.
- Don't write
runtimeEnv: process.env alone in a Vite app — config evaluation needs
process.env, while the browser build needs import.meta.env; merge both.
- Don't hand-write an
Env type — createEnv infers it from the Zod schemas (see zod).
Triggers on
clientPrefix, createEnv, env validation, env-core, env.ts, Firebase Web config, runtimeEnv, t3 env,
t3-env, VITE_, VITE_FIREBASE_