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.
view .mdopen in claudeopen in chatgpt
This is what environment variables look like in a Vite project:
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.
// 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
// 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.
✅ Vite plugin for validating your environment variables
★ 232 · ⑂ 13 · TypeScript
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.
★ 4,009 · ⑂ 128 · TypeScript
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.
// 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"),
},
});
// 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:
// 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:
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
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
# 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
The env.ts layer for Vite — define once, validate everywhere, import with types.
★ 4 · ⑂ 0 · TypeScript
pnpm add @vite-env/core zod
If something does not work, or the docs are unclear, open an issue.