{
"$type": "site.standard.document",
"bskyPostRef": {
"cid": "bafyreihsxpgjns7st5opbt7wateuu6d557tveapto4d3cihe3eb5k6gjju",
"uri": "at://did:plc:25rdn5elo5izoxrmtis34zuk/app.bsky.feed.post/3mpb6rcedxm72"
},
"coverImage": {
"$type": "blob",
"ref": {
"$link": "bafkreigppvu35fk6ssfzoksnb7ckhbsmejwlhotee6auqvs7tj5a6qc2oe"
},
"mimeType": "image/webp",
"size": 144498
},
"path": "/ctrotech/framework-specific-env-patterns-27e6",
"publishedAt": "2026-06-27T09:43:39.000Z",
"site": "https://dev.to",
"tags": [
"nextjs",
"vite",
"node",
"tutorial",
"GitHub",
"Docs",
"npm",
"Testing and Debugging Your Env Config",
"@ctroenv"
],
"textContent": "Your schema is portable. But each runtime loads environment variables differently. CtroEnv adapters bridge the gap — same validation logic, different data sources.\n\n## Node.js: process.env + .env Files\n\nThe `@ctroenv/node` adapter loads `.env` files and wraps `process.env`:\n\n\n\n import { defineEnv, string, number } from \"@ctroenv/core\"\n import { loadEnv } from \"@ctroenv/node\"\n\n const env = defineEnv(schema, { source: loadEnv() })\n\n\n`loadEnv()` resolves files in order:\n\n 1. `.env` — shared defaults\n 2. `.env.{NODE_ENV}` — environment-specific (`.env.development`, `.env.production`)\n 3. `.env.local` — local overrides (gitignored)\n\n\n\nLater files override earlier ones. `process.env` takes precedence unless `override: true`.\n\n### Monorepo Root\n\n\n loadEnv({ path: \"../..\" }) // look up two directories for root .env\n\n\n### Native Node 22+\n\nNode 22 has built-in `process.loadEnvFile()`. Use `native: true` to delegate:\n\n\n\n loadEnv({ native: true }) // uses process.loadEnvFile() if available\n\n\nFalls back to the custom parser on older Node versions.\n\n### System Fallback\n\nBy default, only file values are returned. With `system: true`, missing keys fall through to `process.env`:\n\n\n\n loadEnv({ system: true })\n\n\n### Standalone Parser\n\nUse `parseEnvFile()` directly for custom file loading:\n\n\n\n import { parseEnvFile } from \"@ctroenv/node\"\n\n const content = readFileSync(\".env.custom\", \"utf-8\")\n const vars = parseEnvFile(content)\n\n\nHandles quotes, multiline values (backslash continuation), interpolation (`${VAR}`), comments, and `export` prefix.\n\n## Vite: Build-Time Validation\n\nThe `@ctroenv/vite` plugin validates during the build:\n\n\n\n // vite.config.ts\n import { ctroenvPlugin } from \"@ctroenv/vite\"\n\n export default defineConfig({\n plugins: [\n ctroenvPlugin({ schema: \"./src/env.ts\" }),\n ],\n })\n\n\nIf `DATABASE_URL` is missing, the build fails — no broken artifacts shipped.\n\n### Schema Options\n\nPass a file path or inline definition:\n\n\n\n // File path — imports the module, looks for `schema` export\n ctroenvPlugin({ schema: \"./src/env.ts\" })\n\n // Inline definition\n ctroenvPlugin({\n schema: {\n DATABASE_URL: string().url(),\n PORT: number().port().default(3000),\n },\n })\n\n\n### Fail on Error\n\n\n ctroenvPlugin({ schema: \"./src/env.ts\", failOnError: false })\n // warns instead of failing — useful for optional env vars\n\n\n### viteSource()\n\nUse with `defineEnv()` directly in Vite code:\n\n\n\n import { defineEnv } from \"@ctroenv/core\"\n import { viteSource } from \"@ctroenv/vite\"\n\n const env = defineEnv(schema, { source: viteSource() })\n\n\n`viteSource()` reads from `import.meta.env` first, then falls back to `process.env`.\n\n## Next.js: Server/Client Split\n\nNext.js bundles code for the browser. Server-only env vars must never reach the client bundle. The `@ctroenv/nextjs` adapter enforces this at runtime:\n\n\n\n import { string, type ClientServerSchema } from \"@ctroenv/core\"\n import { defineEnv } from \"@ctroenv/nextjs\"\n\n const schema = {\n server: {\n DATABASE_URL: string().url(),\n JWT_SECRET: string().min(32).secret(),\n },\n client: {\n NEXT_PUBLIC_API_URL: string().url(),\n },\n } satisfies ClientServerSchema\n\n const env = defineEnv(schema)\n\n\nServer components access everything. Client components can only access `NEXT_PUBLIC_` variables — accessing a server var throws:\n\n\n\n Server-only environment variable \"DATABASE_URL\" is not accessible on the client.\n Prefix it with NEXT_PUBLIC_ to expose it.\n\n\n### Build-Time Validation\n\nWrap your Next.js config:\n\n\n\n // next.config.ts\n import { withCtroEnv } from \"@ctroenv/nextjs\"\n\n export default withCtroEnv(schema, nextConfig)\n\n\nValidates at config load time — before the build starts.\n\n### Accessing Secrets\n\nServer secrets are masked (`\"********\"`). Use `meta.get()` for raw values:\n\n\n\n env.JWT_SECRET // \"********\"\n env.meta.get(\"JWT_SECRET\") // actual value\n\n\n## Choosing an Adapter\n\nRuntime | Adapter | Source | Best for\n---|---|---|---\nNode.js | `@ctroenv/node` | `.env` files + `process.env` | APIs, CLIs, servers\nVite | `@ctroenv/vite` | `import.meta.env` | Frontend apps, SSG\nNext.js | `@ctroenv/nextjs` | Server/client split | Full-stack apps\nCloudflare Workers | core's `workersSource()` | Worker env binding | Edge functions\nDeno/Bun | core's `detectSource()` | Auto-detected | Cross-runtime apps\n\nAll adapters use the same schema. Switch between them by changing the source.\n\n\n\n npm install @ctroenv/node @ctroenv/vite @ctroenv/nextjs\n\n\n**Links:** GitHub · Docs · npm\n\n**Previous:Type-Safe Env Vars Without Zod**\n**Next: Testing and Debugging Your Env Config**",
"title": "Framework-Specific Env Patterns"
}