DEV Community

ファース
ファース

Posted on

Why I Built a New Vite Env Plugin

The four problems with plain Vite environment variables — and the plugin I wrote to fix them.

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
Enter fullscreen mode Exit fullscreen mode

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

Four problems. I built a plugin that fixes all of them.

Problem 1: Everything is a string

You coerce values yourself. A forgotten Number() or a
=== true on a string is a quiet bug that passes every check until
it doesn't.

Problem 2: No server/client boundary

Variables prefixed with VITE_ go to the client. Everything else
stays server-side. That's the convention. There's no enforcement,
no explicit split, no warning if you cross the line.

// shared/config.ts — imported in both server and client code
export const db = process.env.DATABASE_URL // silently bundled
Enter fullscreen mode Exit fullscreen mode

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

Problem 3: No leak detection

Even careful code can leak. Bundlers inline values. After
tree-shaking and minification, the literal string value of a server
secret can appear inside a client chunk — no import reference, just
the raw value embedded in compiled output. Nothing checks for this.

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
}
Enter fullscreen mode Exit fullscreen mode

These drift. The variable is in .env. TypeScript doesn't complain.
The mismatch goes unnoticed.

What already exists

Two tools address parts of this.

@julr/vite-plugin-validate-env
validates env at build time and injects values into import.meta.env.
Supports Standard Schema (Zod, Valibot, ArkType) and a lightweight
built-in validator. Zero runtime overhead. It does exactly what it
promises — validation. It doesn't split server/client variables,
doesn't provide virtual modules, and doesn't detect leaks.

@t3-oss/env-core validates
at import time and provides runtime server/client protection via a
Proxy. Platform presets for Vercel, Railway, Netlify, and others.
The extends system works well for monorepos. The trade-offs:
runtimeEnv requires listing every variable twice, there's no
build-time leak detection, and it's framework-agnostic — it can't
hook into Vite's build pipeline.

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

One file, everything derived

@vite-env/core. One
env.ts file. The plugin handles validation, virtual modules, type
generation, and leak detection from it.

// 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'),
  },
})
Enter fullscreen mode Exit fullscreen mode
// vite.config.ts
import ViteEnv from '@vite-env/core/plugin'
import { defineConfig } from 'vite'

export default defineConfig({
  plugins: [ViteEnv()],
})
Enter fullscreen mode Exit fullscreen mode

That's the entire setup.

Validation runs at build start. Missing or malformed variables
fail immediately with a list of every problem at once. During dev,
.env changes revalidate — terminal warning, no 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
Enter fullscreen mode Exit fullscreen mode

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

Type generation writes vite-env.d.ts on every build start.
Add a variable to env.ts, the declaration file updates. Nothing
to maintain 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. Three modes: 'error' (hard fail), 'warn'
(default — logs and exits with code 1), 'stub' (returns a module
that throws at access time, useful for isomorphic framework files).
The default changes to 'error' in 1.0.0 — set it explicitly now
if you're already using 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)),
  },
})
Enter fullscreen mode Exit fullscreen mode

Same plugin, same virtual modules, same leak detection. The generated
.d.ts types are less specific than with Zod — Standard Schema
doesn't expose the same type introspection — but 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(),
  },
})
Enter fullscreen mode Exit fullscreen mode

Available: vercel, railway, netlify. Your definitions take
precedence over preset values.

What I chose not to do

No runtime Proxy. t3-env throws at runtime when you access a
server variable from the client. I chose build-time enforcement
instead. Virtual modules and TypeScript catch it before the code
runs. If you bypass TypeScript deliberately, there's no runtime
throw — that's the trade-off, and I think it's the right one for
a build tool.

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

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

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
Enter fullscreen mode Exit fullscreen mode

vite-env generate is the most useful one onboarding-wise. Run it
once and new developers get a documented .env.example with types,
defaults, and required markers — all from the same schema.

Where to find it

pnpm add @vite-env/core zod
Enter fullscreen mode Exit fullscreen mode

If something doesn't work or the docs are unclear, open an issue.

Top comments (0)