Skip to main content
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:
src/config/index.ts
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:
src/utils/config.util.ts
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;
};

TypeScript autocomplete on import.meta.env

src/env.d.ts augments Vite’s types so each VITE_* variable is typed and autocompletable:
src/env.d.ts
/// <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
ModeFileVITE_APP_ENV
development.env.developmentdevelopment
staging.env.stagingstaging
production.env.productionproduction
A development file looks like this:
.env.development
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

1

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.
.env.development
VITE_STRIPE_PUBLIC_KEY=pk_test_TYooMQauvdEDq54NiTphI7jx
2

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.
src/config/index.ts
const schema = z.object({
  // ...existing keys
  STRIPE_PUBLIC_KEY: z.string(),
});
3

Map it in processEnv

Wire the raw VITE_ name to the config key in the same file.
src/config/index.ts
const processEnv = {
  // ...existing keys
  STRIPE_PUBLIC_KEY: import.meta.env.VITE_STRIPE_PUBLIC_KEY,
} as Record<keyof Config, string | undefined>;
4

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.