Your schema is portable. But each runtime loads environment variables differently. CtroEnv adapters bridge the gap — same validation logic, different data sources.
Node.js: process.env + .env Files
The @ctroenv/node adapter loads .env files and wraps process.env:
import { defineEnv, string, number } from "@ctroenv/core"
import { loadEnv } from "@ctroenv/node"
const env = defineEnv(schema, { source: loadEnv() })
loadEnv() resolves files in order:
-
.env— shared defaults -
.env.{NODE_ENV}— environment-specific (.env.development,.env.production) -
.env.local— local overrides (gitignored)
Later files override earlier ones. process.env takes precedence unless override: true.
Monorepo Root
loadEnv({ path: "../.." }) // look up two directories for root .env
Native Node 22+
Node 22 has built-in process.loadEnvFile(). Use native: true to delegate:
loadEnv({ native: true }) // uses process.loadEnvFile() if available
Falls back to the custom parser on older Node versions.
System Fallback
By default, only file values are returned. With system: true, missing keys fall through to process.env:
loadEnv({ system: true })
Standalone Parser
Use parseEnvFile() directly for custom file loading:
import { parseEnvFile } from "@ctroenv/node"
const content = readFileSync(".env.custom", "utf-8")
const vars = parseEnvFile(content)
Handles quotes, multiline values (backslash continuation), interpolation (${VAR}), comments, and export prefix.
Vite: Build-Time Validation
The @ctroenv/vite plugin validates during the build:
// vite.config.ts
import { ctroenvPlugin } from "@ctroenv/vite"
export default defineConfig({
plugins: [
ctroenvPlugin({ schema: "./src/env.ts" }),
],
})
If DATABASE_URL is missing, the build fails — no broken artifacts shipped.
Schema Options
Pass a file path or inline definition:
// File path — imports the module, looks for `schema` export
ctroenvPlugin({ schema: "./src/env.ts" })
// Inline definition
ctroenvPlugin({
schema: {
DATABASE_URL: string().url(),
PORT: number().port().default(3000),
},
})
Fail on Error
ctroenvPlugin({ schema: "./src/env.ts", failOnError: false })
// warns instead of failing — useful for optional env vars
viteSource()
Use with defineEnv() directly in Vite code:
import { defineEnv } from "@ctroenv/core"
import { viteSource } from "@ctroenv/vite"
const env = defineEnv(schema, { source: viteSource() })
viteSource() reads from import.meta.env first, then falls back to process.env.
Next.js: Server/Client Split
Next.js bundles code for the browser. Server-only env vars must never reach the client bundle. The @ctroenv/nextjs adapter enforces this at runtime:
import { string, type ClientServerSchema } from "@ctroenv/core"
import { defineEnv } from "@ctroenv/nextjs"
const schema = {
server: {
DATABASE_URL: string().url(),
JWT_SECRET: string().min(32).secret(),
},
client: {
NEXT_PUBLIC_API_URL: string().url(),
},
} satisfies ClientServerSchema
const env = defineEnv(schema)
Server components access everything. Client components can only access NEXT_PUBLIC_ variables — accessing a server var throws:
Server-only environment variable "DATABASE_URL" is not accessible on the client.
Prefix it with NEXT_PUBLIC_ to expose it.
Build-Time Validation
Wrap your Next.js config:
// next.config.ts
import { withCtroEnv } from "@ctroenv/nextjs"
export default withCtroEnv(schema, nextConfig)
Validates at config load time — before the build starts.
Accessing Secrets
Server secrets are masked ("********"). Use meta.get() for raw values:
env.JWT_SECRET // "********"
env.meta.get("JWT_SECRET") // actual value
Choosing an Adapter
| Runtime | Adapter | Source | Best for |
|---|---|---|---|
| Node.js | @ctroenv/node |
.env files + process.env
|
APIs, CLIs, servers |
| Vite | @ctroenv/vite |
import.meta.env |
Frontend apps, SSG |
| Next.js | @ctroenv/nextjs |
Server/client split | Full-stack apps |
| Cloudflare Workers | core's workersSource()
|
Worker env binding | Edge functions |
| Deno/Bun | core's detectSource()
|
Auto-detected | Cross-runtime apps |
All adapters use the same schema. Switch between them by changing the source.
npm install @ctroenv/node @ctroenv/vite @ctroenv/nextjs
Previous: Type-Safe Env Vars Without Zod
Next: Testing and Debugging Your Env Config
Top comments (0)