> ## Documentation Index
> Fetch the complete documentation index at: https://ship.paralect.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Environment Variables

> Typed, Vite-prefixed, Zod-validated. One config object, no guesswork.

`apps/web` reads environment variables through [Vite](https://vite.dev/guide/env-and-mode), validates them with [Zod](https://zod.dev/) 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.

```ts theme={null}
import.meta.env.VITE_API_URL; // available in the browser
```

<Warning>
  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.
</Warning>

## 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:

```ts src/config/index.ts theme={null}
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:

```ts theme={null}
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:

```ts src/utils/config.util.ts theme={null}
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:

```ts src/env.d.ts theme={null}
/// <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`:

```bash theme={null}
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:

```bash .env.development theme={null}
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

<Steps>
  <Step title="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.

    ```bash .env.development theme={null}
    VITE_STRIPE_PUBLIC_KEY=pk_test_TYooMQauvdEDq54NiTphI7jx
    ```
  </Step>

  <Step title="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.

    ```ts src/config/index.ts theme={null}
    const schema = z.object({
      // ...existing keys
      STRIPE_PUBLIC_KEY: z.string(),
    });
    ```
  </Step>

  <Step title="Map it in processEnv">
    Wire the raw `VITE_` name to the config key in the same file.

    ```ts src/config/index.ts theme={null}
    const processEnv = {
      // ...existing keys
      STRIPE_PUBLIC_KEY: import.meta.env.VITE_STRIPE_PUBLIC_KEY,
    } as Record<keyof Config, string | undefined>;
    ```
  </Step>

  <Step title="Read it through config">
    Import `config` and use the typed key. `config.STRIPE_PUBLIC_KEY` is now strongly typed everywhere.

    ```ts theme={null}
    import config from '@/config';

    const stripe = loadStripe(config.STRIPE_PUBLIC_KEY);
    ```
  </Step>
</Steps>

<Tip>
  Keep `src/env.d.ts` in sync too — adding the new `VITE_*` key there gives you autocomplete and type-safety on `import.meta.env`.
</Tip>
