Environment & Configuration
Split public from server-only env vars, validate them at boot, and understand when each value is inlined.
Environment handling is where secrets leak and deploys break. Two rules prevent most incidents: know what is public, and fail fast when config is missing.
Public vs server-only
Any variable prefixed NEXT_PUBLIC_ is inlined into the client bundle at build time — it is shipped to every browser, forever, baked into the JS. Everything else stays server-only and is read at runtime. So:
- API keys, DB URLs, signing secrets → plain names, never
NEXT_PUBLIC_. - Analytics site ids, public base URLs, feature flags safe to expose →
NEXT_PUBLIC_.
Because NEXT_PUBLIC_ values are inlined at build time, changing one requires a rebuild — you cannot swap it at runtime, which surprises people deploying the same image to multiple environments.
Validate at boot, not at use
A missing env var should crash the app on startup with a clear message, not throw a cryptic undefined error deep in a request three days later. Parse process.env through a schema (Zod) once, in a module imported early, and export a typed env object everywhere else. You get autocomplete and a guarantee the value exists.
Example
// env.ts — validated once, typed everywhere. Import early.
import { z } from 'zod';
const schema = z.object({
DATABASE_URL: z.string().url(),
SESSION_SECRET: z.string().min(32),
// Public — safe to inline into the browser bundle
NEXT_PUBLIC_APP_URL: z.string().url(),
});
const parsed = schema.safeParse(process.env);
if (!parsed.success) {
console.error('Invalid environment:', parsed.error.flatten().fieldErrors);
throw new Error('Missing/invalid environment variables');
}
export const env = parsed.data;
// Usage anywhere on the server:
// import { env } from '@/env';
// new Pool({ connectionString: env.DATABASE_URL });
// Secrets NEVER get a NEXT_PUBLIC_ prefix.When to use it
- A team validates all required environment variables at startup with Zod so the app fails fast with a clear error if a secret is missing.
- A developer keeps NEXT_PUBLIC_ variables in .env and secrets in .env.local so the former can be committed safely.
- A CI pipeline sets production environment variables in the deployment platform, never in checked-in files, keeping secrets out of git.
More examples
Validate env vars with Zod at boot
Parsing process.env through Zod at module load time throws a descriptive error if any variable is missing or malformed.
// lib/env.ts
import 'server-only';
import { z } from 'zod';
const envSchema = z.object({
DATABASE_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
NEXT_PUBLIC_APP_URL: z.string().url(),
});
export const env = envSchema.parse(process.env);NEXT_PUBLIC_ vs server-only vars
NEXT_PUBLIC_ variables are string-replaced in the client bundle at build time — never use this prefix for secrets.
# .env.local
DATABASE_URL=postgres://... # server only
STRIPE_SECRET_KEY=sk_live_... # server only
NEXT_PUBLIC_STRIPE_PK=pk_live_... # inlined in client bundle
NEXT_PUBLIC_APP_URL=https://acme.com # inlined in client bundleTyped env usage in a component
Importing env from the validated module gives TypeScript types and a guaranteed non-null secret at runtime.
// Typed access via the validated env object
import { env } from '@/lib/env';
export async function getStripeClient() {
const Stripe = (await import('stripe')).default;
return new Stripe(env.STRIPE_SECRET_KEY);
}
Discussion