External Publication
Visit Post

Framework-Specific Env Patterns

DEV Community [Unofficial] June 27, 2026
Source

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:

  1. .env — shared defaults
  2. .env.{NODE_ENV} — environment-specific (.env.development, .env.production)
  3. .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

Links: GitHub · Docs · npm

Previous:Type-Safe Env Vars Without Zod Next: Testing and Debugging Your Env Config

Discussion in the ATmosphere

Loading comments...