Back to Blog
Dec 22, 20253 min readOnuzulike Anthony

Secrets Management in Next.js: What Gets Exposed and What Doesn't

The .env hierarchy, the NEXT_PUBLIC_ prefix trap, Cloudflare Workers secrets, and runtime validation with Zod.

SecuritySecurityNext.jsDevOps

Next.js has a well-defined environment variable system but also some sharp edges that regularly cause developers to accidentally expose secrets to the browser bundle. Here's the full picture.

The .env Hierarchy

Next.js loads environment files in this order, with later files taking precedence:

.env                    # defaults, committed to git
.env.local              # local overrides, never committed
.env.development        # loaded in `next dev`
.env.development.local  # local dev overrides
.env.production         # loaded in `next build` / `next start`
.env.production.local   # local production overrides

The key rule: any file ending in .local should be in .gitignore. Your .env (with placeholder values) can be committed. Your .env.local (with real secrets) never should be.

The NEXT_PUBLIC_ Trap

Any variable prefixed with NEXT_PUBLIC_ is inlined into the browser bundle at build time. This is by design — it's how you pass build-time config to client components. But it means the value is shipped to every browser that loads your app.

bash
# This is public — appears in your compiled JS
NEXT_PUBLIC_TMDB_BASE_URL=https://api.themoviedb.org/3
 
# This is server-only — never sent to the browser
TMDB_API_KEY=your_secret_key

The common mistake: using NEXT_PUBLIC_API_KEY for a key that grants write access or has rate limits that could be burned by the public. Anything with that prefix is public. Full stop.

NodWatch keeps TMDB_API_KEY server-only and exposes only the base URL as NEXT_PUBLIC_TMDB_BASE_URL. All authenticated TMDB calls go through server actions or API routes.

Server Components and Route Handlers

In Next.js App Router, Server Components run only on the server. You can safely access process.env.SECRET_KEY inside them — it will never be sent to the browser. But the moment you pass that value as a prop to a Client Component, you've potentially exposed it.

tsx
// Safe — SERVER component
async function DataFetcher() {
  const data = await fetch(url, {
    headers: { Authorization: `Bearer ${process.env.API_SECRET}` }
  });
  return <ClientList items={data} />; // Don't pass the secret here
}

Runtime Validation with Zod

The worst time to discover a missing environment variable is at 3am when your deployment fails. Validate all required env vars at startup:

ts
// lib/env.ts
import { z } from 'zod';
 
const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  BETTER_AUTH_SECRET: z.string().min(32),
  TMDB_API_KEY: z.string().min(1),
  STREAM_ENCRYPTION_KEY: z.string().min(32),
  OPENSUBTITLES_API_KEY: z.string().optional(),
});
 
export const env = envSchema.parse(process.env);

Import env from this module everywhere you need an env var. If a required variable is missing, you get a clear error at startup — not a cryptic runtime failure in production.

Cloudflare Workers Secrets

When deploying to Cloudflare Workers via OpenNext, environment variables work differently. Secrets are set via wrangler secret put:

bash
wrangler secret put DATABASE_URL
wrangler secret put BETTER_AUTH_SECRET

These are encrypted at rest and injected at runtime — never visible in your wrangler.toml or source code. Non-secret config (URLs, feature flags) can go in wrangler.toml under [vars].

toml
[vars]
NEXT_PUBLIC_TMDB_BASE_URL = "https://api.themoviedb.org/3"
# Secrets go in: wrangler secret put TMDB_API_KEY

Detecting Leaked Secrets

GitHub's secret scanning detects many known secret formats (AWS keys, Stripe keys, etc.) in public repos. For custom API keys, use the prefix scheme (sk_live_, api_prod_) — tools like truffleHog and gitleaks can scan for these patterns.

Add a pre-commit hook to catch .env.local from being committed accidentally:

bash
# .git/hooks/pre-commit
if git diff --cached --name-only | grep -q '\.env\.local'; then
  echo "ERROR: Attempting to commit .env.local"
  exit 1
fi

Secrets that have been committed to a public repo should be considered compromised and rotated immediately — even if you delete the commit. Git history is public and indexed.