import { z } from "zod";
export const envSchema = z.object({
VITE_API_URL: z.string().url(),
VITE_APP_NAME: z.string().min(1).default("my-app"),
VITE_ENABLE_ANALYTICS: z
.enum(["true", "false"])
.default("false")
.transform((v) => v === "true"),
VITE_LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
VITE_SENTRY_DSN: z.string().url().optional(),
});
export type AppEnv = z.infer<typeof envSchema>;
import { z } from "zod";
import { envSchema, type AppEnv } from "./env.schema";
function assertPrefixed(raw: Record<string, unknown>): void {
const suspicious = Object.keys(raw).filter(
(k) => /^(API|APP|ENABLE|LOG|SENTRY)_/.test(k) && !k.startsWith("VITE_")
);
if (suspicious.length > 0) {
throw new Error(
`Env vars missing required VITE_ prefix and will be dropped by Vite: ${suspicious.join(", ")}`
);
}
}
function parseEnv(): AppEnv {
const raw = import.meta.env as Record<string, unknown>;
assertPrefixed(raw);
try {
return envSchema.parse(raw);
} catch (err) {
if (err instanceof z.ZodError) {
const details = err.issues
.map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`)
.join("\n");
throw new Error(`Invalid environment configuration:\n${details}`);
}
throw err;
}
}
export const env: Readonly<AppEnv> = Object.freeze(parseEnv());
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig(({ mode }) => {
const fileEnv = loadEnv(mode, process.cwd(), "");
const unprefixed = Object.keys(fileEnv).filter(
(k) => /_(URL|NAME|LEVEL|DSN)$/.test(k) && !k.startsWith("VITE_")
);
if (unprefixed.length > 0) {
throw new Error(
`[vite] These .env keys look app-facing but lack VITE_ prefix: ${unprefixed.join(", ")}`
);
}
return {
plugins: [react()],
envPrefix: "VITE_",
define: {
__APP_MODE__: JSON.stringify(mode),
},
};
});
Vite only exposes environment variables prefixed with VITE_ to client code, silently dropping anything else from import.meta.env. That quiet behavior is a common source of bugs: a developer references import.meta.env.API_URL, gets undefined at runtime, and never sees a warning. This snippet centralizes and hardens env access so misconfiguration fails loudly at build/startup time rather than surfacing as broken behavior in production.
The env schema tab defines the contract. It uses zod to declare exactly which VITE_-prefixed keys the app expects, along with their types and coercions — VITE_API_URL must be a valid URL, VITE_ENABLE_ANALYTICS is coerced from the string "true"/"false" into a real boolean, and VITE_LOG_LEVEL is constrained to an enum. Because Vite injects everything as strings, coercion is not optional; the schema is where raw strings become the typed values the rest of the code relies on.
The parseEnv helper tab consumes import.meta.env and runs it through the schema. It deliberately checks for any keys the code touches that lack the VITE_ prefix and throws a descriptive error, turning Vite's silent omission into an explicit failure. On a ZodError it formats each issue with its path and message, so a missing or malformed variable produces a readable message naming the offending key instead of a cryptic undefined. The parsed result is frozen and exported once as env, giving the app a single validated source of truth.
The vite.config.ts tab shows the build-time side. loadEnv reads .env files for the active mode, and an explicit assertion rejects any variable that looks app-facing but is missing the prefix, catching typos like VIT_API_URL before the dev server even starts. envPrefix is pinned to VITE_ to make the boundary intentional rather than implicit.
The trade-off is a small amount of upfront ceremony per variable, but it buys compile-time types via z.infer, fail-fast validation, and autocomplete on env. This pattern is worth reaching for on any non-trivial frontend where a wrong or missing config value would be expensive to discover late.
Related snips
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form"]
static values = { delay: { type: Number, default: 250 } }
Debounced live search with Stimulus + Turbo Streams
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Form Validation Example</title>
<style>
HTML forms with validation and accessibility
import { Application } from "@hotwired/stimulus"
import FormSubmitController from "./controllers/form_submit_controller"
const application = Application.start()
application.debug = false
Disable submit button while Turbo form is submitting
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
url: String,
delay: { type: Number, default: 800 },
Stimulus: autosave draft with Turbo-friendly requests
{
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build"
},
Laravel mix/Vite for asset compilation
Share this code
Here's the card — post it anywhere.