Environment Variables in Next.js
Environment Variables in Next.js
Next.js runs code on both server and client. Environment variables need to be split accordingly. The @ctroenv/nextjs adapter handles this automatically.
The Problem
Next.js bundles your code for the browser. Any env var referenced in client code gets inlined at build time. If you accidentally reference DATABASE_URL in a client component, it's baked into the JavaScript bundle — visible to anyone who views the page source.
Next.js enforces a convention: client-facing variables must be prefixed with NEXT_PUBLIC_. CtroEnv's adapter enforces this at the type level.
Server/Client Schema
Split your schema into server and client blocks:
import { string, number, type ClientServerSchema } from "@ctroenv/core"
import { defineEnv } from "@ctroenv/nextjs"
const schema = {
server: {
DATABASE_URL: string().url(),
JWT_SECRET: string().min(32).secret(),
REDIS_URL: string().url().optional(),
},
client: {
NEXT_PUBLIC_API_URL: string().url(),
NEXT_PUBLIC_APP_NAME: string().min(1),
},
} satisfies ClientServerSchema
const env = defineEnv(schema)
Server components access everything:
// Server Component
env.DATABASE_URL // ✅ string
env.JWT_SECRET // ✅ string
env.NEXT_PUBLIC_API_URL // ✅ string
Client components can only access NEXT_PUBLIC_ variables:
// Client Component
env.NEXT_PUBLIC_API_URL // ✅ string
env.DATABASE_URL // ❌ Throws: "Server-only env var..."
Accessing Secrets Server-Side
Server secrets are masked in the proxy. Use meta.get() for raw values:
// In API route or server action
const jwt = env.meta.get("JWT_SECRET")
const token = sign({ userId }, jwt)
meta works the same as the core package — non-enumerable, excluded from JSON output.
Build-Time Validation
Wrap your Next.js config with withCtroEnv() to validate at build time:
// next.config.ts
import { withCtroEnv } from "@ctroenv/nextjs"
import type { NextConfig } from "next"
const nextConfig: NextConfig = {
// your existing config
}
export default withCtroEnv(schema, nextConfig)
If DATABASE_URL is missing or JWT_SECRET is too short, the build fails immediately with a clear error. No waiting for a deployment to crash.
With App Router
The adapter works with both Pages Router and App Router. Create a shared env module:
// lib/env.ts
import { defineEnv } from "@ctroenv/nextjs"
import { schema } from "./schema"
export const env = defineEnv(schema)
Import env from this module in any server component, route handler, or server action. The Proxy handles server/client enforcement at runtime.
TypeScript Inference
The inferred type merges both server and client schemas:
env.DATABASE_URL // string
env.JWT_SECRET // string
env.NEXT_PUBLIC_API_URL // string
env.NEXT_PUBLIC_APP_NAME // string
No | undefined for required vars. No type assertions needed.
Migration from process.env
Replace:
const dbUrl = process.env.DATABASE_URL!
With:
const dbUrl = env.DATABASE_URL
If DATABASE_URL is missing, you get a typed error at startup instead of a runtime crash in a random API route.
Comparison with Next.js Built-in
| Feature | Next.js process.env | CtroEnv adapter |
|---|---|---|
| Server/client enforcement | Manual (NEXT_PUBLIC_ convention) | Automatic (Proxy throws on server-only access) |
| TypeScript types | string | undefined | Exact inferred types |
| Startup validation | None | withCtroEnv() validates on build |
| Secret masking | None | Proxy masks .secret() values |
| Cross-package schemas | Not supported | extendSchema() for monorepos |