# Why I Built a New Vite Env Plugin

> This article covers four problems with plain Vite environment variables and the plugin I built to fix them.

This is what environment variables look like in a Vite project:

```ts
import.meta.env.VITE_PORT; // "5173" — string, not number
import.meta.env.VITE_DARK; // "true" — string, not boolean
import.meta.env.VITE_API_URL; // string | undefined — no validation
```

Every value arrives as a raw string. There is no validation, no server-client boundary, and no leak detection. The only way to get types is a `vite-env.d.ts` file that you write and maintain by hand.

These are four real problems. I built a plugin that fixes all four.

## Problem 1: Everything Is a String

You must coerce values yourself. A forgotten `Number()` call or a `=== true` check on a string creates a quiet bug. It passes every check until it does not.

## Problem 2: No Server/Client Boundary

Variables prefixed with `VITE_` go to the client. Everything else stays server-side. This is only a convention. Vite enforces nothing, splits nothing explicitly, and warns you of nothing if you cross the line.

```ts
// shared/config.ts — imported in both server and client code
export const db = process.env.DATABASE_URL; // silently bundled
```

If server and client code share a module, secrets travel with it.

## Problem 3: No Leak Detection

Even careful code can leak secrets. Bundlers inline values directly. After tree-shaking and minification, the literal string value of a server secret can appear inside a client chunk. There is no import reference at that point, only the raw value embedded in the compiled output. Nothing checks for this problem.

## Problem 4: Manual Type Maintenance

```ts
// vite-env.d.ts — written and updated by hand
interface ImportMetaEnv {
  readonly VITE_API_URL: string;
  readonly VITE_PORT: string;
  // someone added VITE_FEATURE_FLAG last week and forgot this file
}
```

These type declarations drift over time. A variable exists in `.env`, but TypeScript raises no complaint. The mismatch goes unnoticed.

## What Already Exists

Two tools address parts of this.

https://github.com/Julien-R44/vite-plugin-validate-env

This tool validates environment variables at build time and injects the values into `import.meta.env`. It supports Standard Schema, including Zod, Valibot, and ArkType, plus a lightweight built-in validator. It adds zero runtime overhead. It does exactly what it promises: validation. It does not split server and client variables, does not provide virtual modules, and does not detect leaks.

https://github.com/t3-oss/t3-env

This tool validates at import time and provides runtime server-client protection through a Proxy. It offers platform presets for Vercel, Railway, Netlify, and others. Its `extends` system works well for monorepos. It has trade-offs too: `runtimeEnv` requires you to list every variable twice, it has no build-time leak detection, and it stays framework-agnostic, so it cannot hook into Vite's build pipeline.

Both are good tools. Neither solves all four problems for Vite.

## One File, Everything Derived

`@vite-env/core` needs only one `env.ts` file. The plugin handles validation, virtual modules, type generation, and leak detection, all from that one file.

```ts
// env.ts
import { defineEnv } from "@vite-env/core";
import { z } from "zod";

export default defineEnv({
  server: {
    DATABASE_URL: z.url(),
    JWT_SECRET: z.string().min(32),
    DB_POOL_SIZE: z.coerce.number().int().default(10),
  },
  client: {
    VITE_API_URL: z.url(),
    VITE_APP_NAME: z.string().min(1),
    VITE_DEBUG: z.stringbool().default(false),
    VITE_LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
  },
});
```

```ts
// vite.config.ts
import ViteEnv from "@vite-env/core/plugin";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [ViteEnv()],
});
```

This is the entire setup.

Validation runs at build start. Missing or malformed variables fail immediately, with a list of every problem at once. During development, `.env` changes trigger revalidation. This produces a terminal warning, not a crash.

Virtual modules enforce the split:

```ts
// Client code
import { env } from "virtual:env/client";
env.VITE_API_URL; // string
env.VITE_DEBUG; // boolean — coerced, not "true"
env.DATABASE_URL; // TypeScript error — doesn't exist here

// Server/SSR code
import { env } from "virtual:env/server";
env.DATABASE_URL; // string
env.JWT_SECRET; // string
env.VITE_API_URL; // also available
```

Leak detection scans every client chunk at `generateBundle` for the literal string values of server variables. If the value of `DATABASE_URL` appears anywhere in the browser bundle, the build fails and reports the chunk name.

Type generation writes `vite-env.d.ts` on every build start. When you add a variable to `env.ts`, the declaration file updates automatically. You maintain nothing by hand.

Runtime access protection uses Vite 8's Environment API. If client code imports `virtual:env/server`, the plugin intercepts it during the build. The plugin offers three modes. The `'error'` mode fails hard. The `'warn'` mode is the default: it logs a warning and exits with code 1. The `'stub'` mode returns a module that throws only when accessed, which helps with isomorphic framework files. The default will change to `'error'` in version 1.0.0. Set it explicitly now if you already use the plugin.

## Standard Schema

If you prefer Valibot, ArkType, or any other Standard Schema validator:

```ts
import { defineStandardEnv } from "@vite-env/core";
import * as v from "valibot";

export default defineStandardEnv({
  server: {
    DATABASE_URL: v.pipe(v.string(), v.url()),
  },
  client: {
    VITE_API_URL: v.pipe(v.string(), v.url()),
    VITE_APP_NAME: v.pipe(v.string(), v.minLength(1)),
  },
});
```

You get the same plugin, the same virtual modules, and the same leak detection. The generated `.d.ts` types stay less specific than with Zod, because Standard Schema does not expose the same type introspection. Everything else works identically.

## Platform Presets

```ts
import { defineEnv } from "@vite-env/core";
import { vercel } from "@vite-env/core/presets";
import { z } from "zod";

export default defineEnv({
  presets: [vercel],
  server: {
    DATABASE_URL: z.url(),
  },
  client: {
    VITE_API_URL: z.url(),
  },
});
```

The plugin supports `vercel`, `railway`, and `netlify` as presets. Your own definitions take precedence over preset values.

## What I Chose Not to Do

No runtime Proxy: t3-env throws an error at runtime when client code accesses a server variable. I chose build-time enforcement instead. Virtual modules and TypeScript catch the problem before the code runs. If you bypass TypeScript on purpose, the plugin throws no runtime error. This is the trade-off, and I believe it is the right one for a build tool.

No `runtimeEnv` mapping: t3-env needs this mapping because Next.js tree-shakes `process.env` access and requires explicit references to include variables in the bundle. Vite does not have this problem. The plugin calls `loadEnv()` directly and serves everything through virtual modules. You define each variable only once.

No framework adapters: This plugin stays Vite-specific. It uses `configResolved`, `buildStart`, `resolveId`, `load`, `generateBundle`, `configureServer`, and Vite 8's Environment API. If you use Next.js or Nuxt without Vite, t3-env is the right tool for you.

## The CLI

```bash
# Validate without starting the dev server
npx vite-env check

# Generate .env.example from your schema
npx vite-env generate

# Regenerate vite-env.d.ts manually
npx vite-env types
```

`vite-env generate` is the most useful command for onboarding new developers. Run it once, and new developers get a documented `.env.example` file with types, defaults, and required markers. All of this comes from the same schema.

## Where to Find It

https://github.com/pyyupsk/vite-env

- Docs: [pyyupsk.github.io/vite-env](https://pyyupsk.github.io/vite-env/)

```bash
pnpm add @vite-env/core zod
```

If something does not work, or the docs are unclear, open an issue.
