apps/web reads environment variables through Vite, validates them with Zod at startup, and exposes a single typed config object. If a required variable is missing or malformed, the app fails fast instead of breaking at runtime.
The VITE_ prefix
Vite only exposes variables that start with VITE_ to client code, and it does so on import.meta.env. Anything without the prefix stays server-side and is invisible to the browser.
import.meta.env.VITE_API_URL; // available in the browser
Every VITE_-prefixed variable is bundled into the client and visible to anyone who opens devtools. Never put secrets — API keys with write scope, database URLs, signing keys — behind a VITE_ prefix.
One typed config object
Don’t read import.meta.env directly in components. Read from the validated config object in src/config/index.ts, which is the single source of truth:
import { z } from 'zod';
import { validateConfig } from '@/utils/config.util';
const schema = z.object({
APP_ENV: z.enum(['development', 'staging', 'production']).default('development'),
IS_DEV: z.preprocess(() => import.meta.env.VITE_APP_ENV === 'development', z.boolean()),
API_URL: z.string(),
WS_URL: z.string(),
WEB_URL: z.string(),
MIXPANEL_API_KEY: z.string().optional(),
});
type Config = z.infer<typeof schema>;
const processEnv = {
APP_ENV: import.meta.env.VITE_APP_ENV,
API_URL: import.meta.env.VITE_API_URL,
WS_URL: import.meta.env.VITE_WS_URL,
WEB_URL: import.meta.env.VITE_WEB_URL,
MIXPANEL_API_KEY: import.meta.env.VITE_MIXPANEL_API_KEY,
} as Record<keyof Config, string | undefined>;
const config = validateConfig<Config>(schema, processEnv);
export default config;
The raw VITE_* names map onto clean, prefix-free config keys. Consume them anywhere:
import config from '@/config';
const ws = io(config.WS_URL);
if (config.IS_DEV) {
// dev-only behaviour
}
validateConfig (in src/utils/config.util.ts) parses the env with the schema and throws a readable error if anything is wrong:
export const validateConfig = <T>(schema: ZodType<T>, processEnv: Record<keyof T, string | undefined>): T => {
const parsed = schema.safeParse(processEnv);
if (!parsed.success) {
console.error('❌ Invalid environment variables ❌');
console.error(z.prettifyError(parsed.error));
throw new Error('Invalid environment variables');
}
return parsed.data;
};
src/env.d.ts augments Vite’s types so each VITE_* variable is typed and autocompletable:
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_ENV: string;
readonly VITE_API_URL: string;
readonly VITE_WS_URL: string;
readonly VITE_WEB_URL: string;
readonly VITE_MIXPANEL_API_KEY?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
Per-environment .env files
Vite loads the right file from the mode it runs in. vite dev uses development, vite build uses production, and you can target any mode explicitly with --mode:
vite build --mode staging # loads .env.staging
| Mode | File | VITE_APP_ENV |
|---|
development | .env.development | development |
staging | .env.staging | staging |
production | .env.production | production |
A development file looks like this:
VITE_APP_ENV=development
VITE_API_URL=http://localhost:3001
VITE_WS_URL=http://localhost:3001
VITE_WEB_URL=http://localhost:3002
# VITE_MIXPANEL_API_KEY=...
Adding a new variable
Add it to the .env files
Prefix client-side variables with VITE_ so Vite exposes them. Add the variable to each environment file that needs it.VITE_STRIPE_PUBLIC_KEY=pk_test_TYooMQauvdEDq54NiTphI7jx
Extend the schema in src/config/index.ts
Add the variable to the Zod schema so it’s validated. Use .optional() if it isn’t required in every environment.const schema = z.object({
// ...existing keys
STRIPE_PUBLIC_KEY: z.string(),
});
Map it in processEnv
Wire the raw VITE_ name to the config key in the same file.const processEnv = {
// ...existing keys
STRIPE_PUBLIC_KEY: import.meta.env.VITE_STRIPE_PUBLIC_KEY,
} as Record<keyof Config, string | undefined>;
Read it through config
Import config and use the typed key. config.STRIPE_PUBLIC_KEY is now strongly typed everywhere.import config from '@/config';
const stripe = loadStripe(config.STRIPE_PUBLIC_KEY);
Keep src/env.d.ts in sync too — adding the new VITE_* key there gives you autocomplete and type-safety on import.meta.env.