into client modules. This requires explicit module boundaries.
Step 2: Create a Validated Server Configuration Module
Server configuration should be loaded once, validated against a schema, and exported as a frozen object. This prevents runtime mutation and ensures missing variables fail fast during deployment rather than causing silent errors.
// src/config/server-env.ts
import 'server-only';
import { z } from 'zod';
const serverSchema = z.object({
DATABASE_URL: z.string().url(),
REDIS_CONNECTION: z.string().min(1),
INTERNAL_API_SECRET: z.string().min(32),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
});
type ServerEnv = z.infer<typeof serverSchema>;
function loadServerEnv(): ServerEnv {
const raw = {
DATABASE_URL: process.env.DATABASE_URL,
REDIS_CONNECTION: process.env.REDIS_CONNECTION,
INTERNAL_API_SECRET: process.env.INTERNAL_API_SECRET,
NODE_ENV: process.env.NODE_ENV,
};
const result = serverSchema.safeParse(raw);
if (!result.success) {
const missing = result.error.issues.map((i) => i.path.join('.')).join(', ');
throw new Error(`Missing or invalid server environment variables: ${missing}`);
}
return Object.freeze(result.data);
}
export const serverConfig = loadServerEnv();
Why this works: The server-only package guarantees this module cannot be imported into client components. The compiler will throw a build error if you attempt to cross the boundary. Zod validation ensures deployment fails immediately when required variables are absent, rather than propagating undefined through the application.
Step 3: Create an Explicit Client Configuration Module
Client configuration must be deliberately curated. Only values that are safe for public consumption should be exposed. This module acts as a whitelist, preventing accidental leakage of server-bound variables.
// src/config/client-env.ts
import { z } from 'zod';
const clientSchema = z.object({
NEXT_PUBLIC_API_BASE: z.string().url(),
NEXT_PUBLIC_STRIPE_KEY: z.string().startsWith('pk_'),
NEXT_PUBLIC_FEATURE_FLAGS: z.string().transform((val) => {
try {
return JSON.parse(val);
} catch {
return {};
}
}),
});
type ClientEnv = z.infer<typeof clientSchema>;
function loadClientEnv(): ClientEnv {
const raw = {
NEXT_PUBLIC_API_BASE: process.env.NEXT_PUBLIC_API_BASE,
NEXT_PUBLIC_STRIPE_KEY: process.env.NEXT_PUBLIC_STRIPE_KEY,
NEXT_PUBLIC_FEATURE_FLAGS: process.env.NEXT_PUBLIC_FEATURE_FLAGS,
};
const result = clientSchema.safeParse(raw);
if (!result.success) {
const missing = result.error.issues.map((i) => i.path.join('.')).join(', ');
throw new Error(`Missing or invalid client environment variables: ${missing}`);
}
return Object.freeze(result.data);
}
export const clientConfig = loadClientEnv();
Why this works: By explicitly listing only the NEXT_PUBLIC_ variables you intend to expose, you create a maintainable whitelist. The schema enforces format constraints (e.g., Stripe keys must start with pk_). The Object.freeze() call prevents accidental mutation at runtime.
Step 4: Implement Runtime Configuration for Dynamic Values
Some values cannot be known at build time (e.g., tenant-specific settings, A/B test variants, or environment-dependent URLs that change per deployment). For these scenarios, fetch configuration from a server endpoint rather than embedding it in the bundle.
// src/app/api/config/route.ts
import { NextResponse } from 'next/server';
import { serverConfig } from '@/config/server-env';
export async function GET() {
const publicConfig = {
apiBase: serverConfig.DATABASE_URL.includes('localhost')
? 'http://localhost:3000/api'
: 'https://api.production.example.com',
maxUploadSize: 10485760,
maintenanceMode: process.env.MAINTENANCE_MODE === 'true',
};
return NextResponse.json(publicConfig);
}
Why this works: Runtime configuration keeps sensitive logic on the server while allowing dynamic client behavior. The client fetches only what it needs, and the server controls the payload. This pattern is essential for multi-tenant applications or environments where configuration changes without triggering a full rebuild.
Step 5: Enforce Boundaries with TypeScript and Linting
Configuration leakage often happens through indirect imports. A client component might import a utility that accidentally imports a server module. Prevent this with explicit TypeScript path mapping and ESLint rules.
// tsconfig.json
{
"compilerOptions": {
"paths": {
"@server/*": ["./src/config/server-env.ts"],
"@client/*": ["./src/config/client-env.ts"]
}
}
}
Pair this with a custom ESLint rule or import/no-restricted-paths to block cross-boundary imports. This turns configuration security from a manual review step into an automated build guarantee.
Pitfall Guide
1. Prefix Confusion
Explanation: Developers assume NEXT_PUBLIC_ is a namespace rather than a compiler directive. Using it for database URLs, JWT secrets, or internal API keys embeds them directly into the client bundle.
Fix: Treat NEXT_PUBLIC_ as a security boundary, not a naming convention. Only prefix values that are explicitly intended for public consumption. Audit your .env files regularly for accidental prefixes.
2. Build-Time Assumption
Explanation: Expecting .env changes to reflect in a running application without triggering a rebuild. Next.js inlines client variables at compile time; changing .env.local on a production server has zero effect on already-deployed bundles.
Fix: Implement CI/CD pipelines that trigger full rebuilds when environment files change. For dynamic values, use runtime API endpoints instead of build-time variables.
3. Silent Undefined Propagation
Explanation: Accessing process.env.MISSING_VAR returns undefined without throwing. This causes downstream failures that are difficult to trace, especially in serverless environments where cold starts mask configuration issues.
Fix: Always validate environment variables at module initialization. Use schema validation (Zod, Joi, or custom type guards) and throw explicit errors when required values are absent.
4. Runtime Mutation Attempts
Explanation: Trying to modify process.env or NEXT_PUBLIC_* variables at runtime in client components. The compiler has already replaced these references with static strings; mutation attempts either fail silently or cause hydration mismatches.
Fix: Treat environment configuration as immutable. Use React context, URL state, or server-fetched data for dynamic client configuration. Never attempt to write to process.env in browser code.
5. Over-Reliance on .env.local
Explanation: Assuming .env.local is sufficient for all environments. This file is gitignored by default, which breaks CI/CD pipelines, preview deployments, and team onboarding when variables aren't explicitly provided in the deployment platform.
Fix: Maintain a .env.example file documenting required variables. Use platform-specific environment injection (Vercel, AWS, Docker) for production. Reserve .env.local strictly for local development overrides.
6. Edge Runtime Blind Spots
Explanation: Next.js Edge Runtime has limited access to Node.js APIs and environment variables. Variables not explicitly allowed in experimental.edgeEnvVars (or equivalent configuration) will be undefined in middleware or edge functions.
Fix: Audit which variables are available in Edge contexts. Use next.config.js or next.config.ts to explicitly whitelist environment variables for Edge runtime. Test middleware behavior in isolation before deployment.
7. Hardcoded Fallback Masking
Explanation: Using process.env.KEY || 'default-value' to avoid build failures. This masks missing configuration, deploys with incorrect values, and creates environment drift that is nearly impossible to debug.
Fix: Fail fast. Remove fallbacks for critical configuration. Use validation schemas that require explicit values. If a default is truly needed, document it in the schema and validate it explicitly rather than relying on logical OR operators.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Static API endpoints known at build | NEXT_PUBLIC_ prefix | Zero runtime overhead, predictable bundling | None |
| Database credentials, secrets | Server-only module | Never leaves Node/Edge runtime, zero bundle exposure | None |
| Tenant-specific settings | Runtime API fetch | Dynamic per request, no rebuild required | Minor API latency |
| Feature flags with A/B testing | Runtime API + client cache | Reduces bundle size, enables real-time toggling | Minor API latency + cache logic |
| Multi-region deployments | Runtime config + geo-routing | Avoids rebuilding for region changes | Infrastructure routing cost |
Configuration Template
# .env.example
# Server-only variables (never prefix with NEXT_PUBLIC_)
DATABASE_URL=postgresql://user:pass@host:5432/db
REDIS_URL=redis://host:6379
INTERNAL_API_SECRET=generate-a-secure-random-string
NODE_ENV=development
# Client-safe variables (explicitly prefixed)
NEXT_PUBLIC_API_BASE=https://api.example.com
NEXT_PUBLIC_STRIPE_KEY=pk_test_...
NEXT_PUBLIC_FEATURE_FLAGS={"darkMode":true,"betaFeatures":false}
// src/lib/env-validator.ts
import { z } from 'zod';
export function createEnvValidator<T extends z.ZodRawShape>(shape: T) {
const schema = z.object(shape);
return function validate(prefix: string) {
const raw: Record<string, unknown> = {};
for (const key of Object.keys(shape)) {
const envKey = prefix ? `${prefix}_${key}` : key;
raw[key] = process.env[envKey];
}
const result = schema.safeParse(raw);
if (!result.success) {
const errors = result.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('\n');
throw new Error(`Environment validation failed:\n${errors}`);
}
return Object.freeze(result.data);
};
}
Quick Start Guide
- Create boundary modules: Add
src/config/server-env.ts and src/config/client-env.ts with explicit variable lists and Zod validation.
- Install dependencies: Run
npm install zod server-only to enable schema validation and server-only module enforcement.
- Define your
.env.example: Document all required variables, clearly marking which require the NEXT_PUBLIC_ prefix and which must remain server-bound.
- Add validation to entry points: Import your config modules in
next.config.ts, API routes, and server components. The application will throw immediately if variables are missing or malformed.
- Verify compilation boundaries: Run
next build and inspect the client bundle. Search for your server variable names; they should not appear anywhere in the output. If they do, trace the import chain and enforce module boundaries.