NEXT_PUBLIC_ prefix exposes environment variables to the browser — anything prefixed with NEXT_PUBLIC_ is inlined into the client JavaScript bundle at build time
API keys, database URLs, auth secrets must NEVER use NEXT_PUBLIC_ — only public-facing values (GA tracker ID, public API URLs) belong in the prefix
Runtime environment variable validation with Zod catches missing or misconfigured vars at server start — before they cause production failures
Server-only (no prefix) env vars are replaced at request time on the server — they do not leak to the client
.env.local overrides .env in development — but in production, Vercel/Cloudflare env vars or CI secrets are the source of truth
Biggest mistake: using NEXT_PUBLIC_ for secrets because 'it worked locally' — the value is baked into the JS bundle and visible in browser devtools
✦ Definition~90s read
What is Environment Variables and Configuration in Next.js 16?
Environment variables in Next.js provide a way to configure your application without hardcoding values in source code. The framework extends the standard process.env pattern with a critical security feature: the NEXT_PUBLIC_ prefix. Variables prefixed with NEXT_PUBLIC_ are inlined into the client-side JavaScript bundle at build time, making them accessible to browser code but also publicly visible.
★
NEXT_PUBLIC_ is like writing your house key on a sticky note and taping it to your front door.
Variables without the prefix remain server-only, resolved at request time from the server environment.
The distinction is a security boundary, not a naming convention. NEXT_PUBLIC_ values become part of your static assets — they are served to every browser, visible in devtools, and accessible to any script running on your page. This is by design: it enables client-side code to access configuration like API base URLs or analytics IDs without additional server requests.
But it becomes dangerous when developers use the prefix for API keys, database credentials, or any value that grants access to paid services or sensitive data.
Next.js loads environment variables from multiple .env files with a specific priority order: .env.local (highest, never committed), .env.[environment] (e.g., .env.production), and .env (lowest, committed). In production, variables are set in the hosting platform's environment settings — .env files serve as reference templates, not the source of truth for production secrets.
Runtime validation with Zod at server startup ensures that required variables are present and correctly formatted before the application begins serving requests.
Best practice for secure environment variable management: NEVER use NEXT_PUBLIC_ for secrets, always validate configuration at startup, wrap secret access in server-only code paths (Server Actions or API routes), and implement defense-in-depth on external API keys — IP restrictions, usage limits, monitoring alerts, and automatic rotation. Automated checks at commit time and deploy time (pre-commit hooks, CI bundle scans) catch accidental exposures before they reach production.
Plain-English First
NEXT_PUBLIC_ is like writing your house key on a sticky note and taping it to your front door. Anyone can see it. When you prefix a variable with NEXT_PUBLIC_, Next.js literally bakes that value into your JavaScript bundle at build time. It's visible in the browser's devtools, inspectable in the source code, and accessible to any script running on your page. API keys, database passwords, and auth tokens must never use the prefix. The server-only (no prefix) variables are like keys kept inside the house — only the server can access them. They never reach the browser.
⚙ Browser compatibility
Latest versions — ✓ supported
Chrome
Firefox
Safari
Edge
✓
✓
✓
✓
The NEXT_PUBLIC_ prefix in Next.js is the single most dangerous configuration pattern in the framework. It looks like any other environment variable — you put it in .env.local, access it with process.env.NEXT_PUBLIC_API_KEY, and it works locally. But unlike un-prefixed variables, NEXT_PUBLIC_ values are inlined into the client-side JavaScript bundle at build time. They are visible to anyone who opens browser devtools, views your page source, or inspects your network bundle.
An OpenAI API key prefixed with NEXT_PUBLIC_ was scraped by a bot within 4 hours of deployment. The bot used the exposed key to generate images and text, accumulating $8,000 in charges before the team noticed. The key had billing limits — but they were set to $10,000/month, not $0. The key was valid, unrestricted, and publicly accessible.
In this article, you'll learn the exact rules of Next.js environment variable exposure: what gets inlined, what stays server-only, how to validate configuration at runtime with Zod, how to build a server-only config pattern, and the production security measures that prevent leaked secrets from causing financial damage.
The NEXT_PUBLIC_ Prefix — What It Actually Does
NEXT_PUBLIC_ is not a naming convention. It is a build-time substitution directive. When Next.js encounters process.env.NEXT_PUBLIC_SOME_VAR in your source code, it replaces the entire expression with the literal string value of the environment variable at BUILD time. This happens for every file — server code, client code, config files, everything.
The substitution is a simple string replacement. If NEXT_PUBLIC_API_URL='https://api.example.com' and your code has process.env.NEXT_PUBLIC_API_URL, the build output contains the literal string 'https://api.example.com' — no process.env reference remains.
This means: NEXT_PUBLIC_ values are baked into your JavaScript bundles. They cannot be changed without a rebuild. They are visible to any client-side code. There is no runtime resolution — the value is what it was at the moment the build ran.
Un-prefixed variables (e.g., DATABASE_URL) are NOT inlined. They remain as process.env.DATABASE_URL in the source, resolved at request time on the server. They never reach the client bundle.
check-public-vars.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# ============================================
# Find NEXT_PUBLIC_ variables that look like secrets
# ============================================
# === Check all .env files for NEXT_PUBLIC_ with suspicious names ===
grep -rn 'NEXT_PUBLIC_' .env* 2>/dev/null | grep -i 'key\|secret\|token\|password\|auth\|api'
# === Checkif secrets are exposed in the built bundle ===
# Lookfor common API key patterns in the compiled JS
rg 'sk-[A-Za-z0-9]{20,}' .next/static/ 2>/dev/null || echo 'No OpenAI keys found in bundle'
rg 'AKIA[0-9A-Z]{16}' .next/static/ 2>/dev/null || echo 'No AWS keys found in bundle'
# === Check all .env.{environment} files ===
ls -la .env*
# === Verify which env vars are set ===
node -e "console.log(process.env.NEXT_PUBLIC_API_KEY || 'NOT SET')"
# === List all NEXT_PUBLIC_ references in source ===
grep -rn 'NEXT_PUBLIC_' src/ app/ lib/ --include='*.ts' --include='*.tsx' --include='*.js' --include='*.jsx'
NEXT_PUBLIC_ = Public by Design
There is no way to make a NEXT_PUBLIC_ variable server-only after you've used the prefix. Once you use NEXT_PUBLIC_, the value is inlined at build time and visible to every browser that loads your app. The only fix is to rename the variable (remove the prefix), regenerate the key, and move its usage to a server context.
Production Insight
The developer who leaked $8,000 worth of OpenAI credits didn't realize NEXT_PUBLIC_ was anything more than a naming convention. They saw it in the Next.js docs and used it because 'I need this in a client component.' The build succeeded, the app worked, and the key was visible in the bundle from the first deployment.
Rule: if you're not sure whether to use NEXT_PUBLIC_, assume NO. You can always add it later. You cannot remove it without a redeploy and key rotation.
Key Takeaway
NEXT_PUBLIC_ inlines values at build time — not runtime.
Secrets in NEXT_PUBLIC_ are visible in the browser bundle.
There is no way to make a NEXT_PUBLIC_ variable server-only after it's in the prefix.
thecodeforge.io
Nextjs Environment Variables Config
Server-Only Secrets — The Correct Pattern for API Keys and Tokens
Server-only environment variables never use the NEXT_PUBLIC_ prefix. They are accessed on the server only: in Server Actions, API routes, Server Components, getServerSideProps, or middleware. The value remains as process.env.VAR_NAME — resolved at request time, never serialized into the client bundle.
To use a server-only variable in a client context, you must create a Server Action or API route that accesses the variable and returns the result. Alternatively, fetch the data in a Server Component and pass it as props to a Client Component.
The pattern: define all secrets in .env.local (and their production values in the hosting environment). Validate them at server startup with a Zod schema. Access them only in server code paths. Never pass the raw variable to the client — only pass the result of processing it.
server-only-env.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// ============================================// Server-Only Environment Variables// ============================================// ---- WRONG: Accessing server-only env in client component ----// This file has 'use client' — process.env.OPENAI_KEY is undefined here'use client'functionWrongComponent() {
// process.env.OPENAI_KEY is undefined in the browserreturnnull
}
// ---- CORRECT: Server Action handles the secret ----// File: app/actions/generate.ts'use server'importOpenAIfrom'openai'exportasyncfunctiongenerateDescription(
prompt: string
): Promise<string> {
// This runs on the server — OPENAI_KEY is available hereconst openai = newOpenAI({
apiKey: process.env.OPENAI_KEY,
})
const response = await openai.chat.completions.create({
model: 'gpt-4',
messages: [{ role: 'user', content: prompt }],
})
return response.choices[0]?.message?.content || ''
}
// ---- CORRECT: Server Component fetches, client renders ----// File: app/products/[slug]/page.tsx (Server Component)exportdefaultasyncfunctionProductPage({
params,
}: {
params: { slug: string }
}) {
// Server Component has access to all env varsconst apiUrl = process.env.API_URL
const product = awaitfetch(`${apiUrl}/products/${params.slug}`).then(
r => r.json()
)
return <ProductDetails product={product} />
}
// ---- CORRECT: lib/env.ts validates at startup ----exportfunctiongetServerConfig() {
return {
openaiKey: process.env.OPENAI_KEY,
databaseUrl: process.env.DATABASE_URL,
redisUrl: process.env.REDIS_URL,
}
}
Server-only env vars (no prefix) are resolved at request time on the server — never reach the client
NEXT_PUBLIC_ env vars are inlined at build time into the client bundle — publicly visible
To use a secret in a client feature, create a Server Action or API route that wraps the secret access
Validate all server env vars at startup with Zod — crash early if secrets are missing
Production Insight
The fix for the leaked OpenAI key was straightforward: move the API call to a Server Action, remove the NEXT_PUBLIC_ prefix from the env var, regenerate the key, and add IP restrictions. The client component that generated product descriptions now calls the Server Action instead of accessing the key directly.
Rule: never access process.env in a client component. If you need server data in a client component, use a Server Action, API route, or pass data from a Server Component as props.
Key Takeaway
Server-only env vars stay on the server — never use NEXT_PUBLIC_ for secrets.
Wrap secret access in Server Actions or API routes.
Never pass raw secrets to client components — only pass processed results.
Runtime Environment Variable Validation with Zod
Environment variables are configuration. They can be missing, wrong, or malformed — and these failures often manifest as confusing runtime errors ('Cannot read properties of undefined') instead of clear startup failures. Runtime validation at server startup catches these issues immediately.
The pattern: define a Zod schema for your environment variables. Parse process.env on module load. If validation fails, log a clear error message showing exactly which variables are missing or invalid, then crash the process. This prevents deploying with broken configuration.
A secondary benefit: the validated config object gives you type-safe access to environment variables throughout your app — autocompletion, type checking, and no undefined surprises.
env-validation.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
// ============================================// Runtime Environment Variable Validation// ============================================import { z } from'zod'// ---- Schema for all required environment variables ----const envSchema = z.object({
// Database
DATABASE_URL: z
.string()
.url('DATABASE_URL must be a valid URL'),
// Auth
AUTH_SECRET: z
.string()
.min(32, 'AUTH_SECRET must be at least 32 characters'),
AUTH_ORIGIN: z
.string()
.url('AUTH_ORIGIN must be a valid URL'),
// External APIs
OPENAI_KEY: z
.string()
.min(1, 'OPENAI_KEY is required'),
STRIPE_SECRET_KEY: z
.string()
.startsWith('sk_', 'STRIPE_SECRET_KEY must start with sk_'),
// Optional with defaults
NODE_ENV: z
.enum(['development', 'production', 'test'])
.default('development'),
LOG_LEVEL: z
.enum(['debug', 'info', 'warn', 'error'])
.default('info'),
// Public-facing values (safe for NEXT_PUBLIC_)
NEXT_PUBLIC_GA_ID: z
.string()
.regex(/^G-[A-Z0-9]+$/, 'GA_ID must be a valid Google Analytics ID')
.optional(),
NEXT_PUBLIC_API_URL: z
.string()
.url('API_URL must be a valid URL')
.optional(),
})
exporttypeEnv = z.infer<typeof envSchema>
// ---- Parse and validate at module load ----const parsed = envSchema.safeParse(process.env)
if (!parsed.success) {
console.error('❌ Invalid environment variables:')
for (const issue of parsed.error.issues) {
console.error(` - ${issue.path.join('.')}: ${issue.message}`)
}
console.error('\nFix the above missing/misconfigured variables before starting.')
process.exit(1)
}
exportconst env: Env = parsed.data
// ---- Use in your app ----import { env } from'@/lib/env'// Type-safe, validated accessconst db = newPool({ connectionString: env.DATABASE_URL })
Run your Zod env validation at module load time (process startup), not on the first request. This fails fast — you know within seconds of deployment that a variable is missing, instead of discovering it when a user hits the affected feature.
Production Insight
A team deployed a major feature update but forgot to add the new STRIPE_SECRET_KEY to the production environment. Without runtime validation, the deployment appeared successful. The first user who tried to purchase crashed the Server Action with 'process.env.STRIPE_SECRET_KEY is undefined' — a confusing error on the checkout page. With Zod validation at startup, the deployment would have failed immediately with a clear message.
Rule: every Next.js project should have a lib/env.ts that validates all environment variables on server start. Make it a scaffolding step for new projects.
Key Takeaway
Validate all environment variables at server startup with Zod.
Crash on missing or invalid variables — fail fast, not on first user request.
Type-safe env access with autocompletion and no undefined surprises.
NEXT_PUBLIC_ vs Server-Only Env VarsWhy prefix choice determines security postureNEXT_PUBLIC_ PrefixServer-Only Env VarsExposure in client bundleInlined at build time, visible in browseNever sent to client, stays on serverUse casePublic values (e.g., API base URL)Secret keys (e.g., OpenAI API key)Runtime validationNot typically validated, assumed publicValidated with Zod before useSecurity riskHigh — key can be extracted by anyoneLow — only accessible in server contextBest practiceAvoid for any sensitive dataAlways use for API keys and secretsTHECODEFORGE.IO
thecodeforge.io
Nextjs Environment Variables Config
Security Measures for External API Keys — Beyond Environment Variables
Environment variables keep secrets out of your codebase and client bundles. But they don't protect against all attack vectors. If your server is compromised, the attacker reads your environment variables. If an API key is exposed (via logs, error messages, or a bug), there are no additional safeguards.
Defense in depth for API keys: IP restrictions (only allow the server's IP to call the API), usage limits and alerts (hard caps and notification thresholds on the provider dashboard), key rotation (rotate keys regularly, or immediately on exposure), and least privilege (create keys with minimum needed permissions — a read-only key doesn't need write access).
For the OpenAI incident: the key had NO IP restrictions, a $10K/month limit with no alerts below that, and full access to all OpenAI models. Fixing all four — IP lock, $100 limit with $50 alert, model-specific key (gpt-4o only), and automatic rotation — would have limited the damage to at most $100 even if the key was leaked.
api-key-security.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// ============================================// API Key Security Utility// ============================================import { env } from'@/lib/env'// ---- Verify IP restrictions (run before using the key) ----exportfunctionverifyServerIP(): boolean {
// This runs on the server — check if we're calling from the expected IP// Implementation depends on your hosting platformreturntrue
}
// ---- Usage monitoring wrapper ----interfaceAPIUsage {
endpoint: string
tokens: number
cost: number
timestamp: number
}
asyncfunctiontrackAPIUsage(usage: APIUsage) {
// Log usage to your monitoring system
console.log('API_USAGE', usage)
// Check against budgetconst dailyCost = awaitgetDailyCost()
const DAILY_LIMIT = 50// $50 daily limitif (dailyCost + usage.cost > DAILY_LIMIT) {
thrownewError(
'API budget limit reached. Please try again tomorrow.'
)
}
}
// ---- Wrapped OpenAI client with budget checks ----importOpenAIfrom'openai'const openai = newOpenAI({ apiKey: env.OPENAI_KEY })
exportasyncfunctiongenerateWithBudget(
prompt: string
): Promise<string> {
const response = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [{ role: 'user', content: prompt }],
})
const usage = {
tokens: response.usage?.total_tokens || 0,
cost: (response.usage?.total_tokens || 0) * 0.00001, // gpt-4o pricing
endpoint: 'chat.completions',
timestamp: Date.now(),
}
// Non-blocking tracking — don't fail the request on tracking errortrackAPIUsage(usage).catch(console.error)
return response.choices[0]?.message?.content || ''
}
Environment Variables Are Not a Security Silver Bullet
Keeping secrets out of the client bundle is necessary but not sufficient. A compromised server process, a verbose error message that includes the key, or a dependency that logs process.env will all leak server-side secrets. Use defense in depth: IP restrictions, usage limits, least privilege, and automatic rotation.
Production Insight
The $8,000 incident could have been prevented at multiple levels: no NEXT_PUBLIC_ prefix (move to Server Action), IP restrictions on the OpenAI key (only server IP can call), usage limits ($100 hard cap), and monitoring alerts ($50 threshold). The team implemented all four after the incident. Six months later, when another developer accidentally exposed a different key, the IP restriction and $100 limit limited the damage to $3 — the attacker's IP wasn't whitelisted.
Rule: assume every environment variable will eventually be exposed. Design your API key security so that exposure is an inconvenience, not a disaster.
Key Takeaway
Environment variables are one layer of defense — not the only layer.
IP restrictions, usage limits, least privilege, and rotation protect against exposure.
Assume every secret will be exposed — design API key security accordingly.
.env Files — The Complete File Resolution Order
Next.js loads environment variables from multiple .env files in a specific priority order. Understanding this order is essential for debugging why an environment variable has an unexpected value.
The resolution order (highest priority first): .env.local (overrides all others, NEVER committed), .env.[environment] (e.g., .env.production), .env (default, committed). The environment is determined by NEXT_PUBLIC_VERCEL_ENV on Vercel, or the NODE_ENV value otherwise.
Critical rule: .env.local overrides .env.production on your local machine. This means 'npm run build' locally uses .env.local values, not .env.production values. A NEXT_PUBLIC_ variable that's set in .env.local but not in your CI/CD environment will be undefined in production builds.
In production (Vercel, Cloudflare, AWS), environment variables are set in the hosting platform's dashboard, NOT in .env files. The .env.production file in your repo is used as a reference for which variables are expected — but the actual values come from the platform environment.
env-resolution.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# ============================================
# .env FileResolutionOrder
# ============================================
# === Filehierarchy (highest priority first) ===
# 1. .env.local — Local overrides, NEVER committed
# 2. .env.development — Development-specific
# 3. .env.production — Productiondefaults (values for reference)
# 4. .env.test — Test environment
# 5. .env — Shared defaults, committed
# === When each is loaded ===
# next dev → .env.development + .env.local
# next build → .env.production + .env.local
# next start → .env.production + .env.local
# npm test → .env.test + .env.local
# === Production environments ===
# OnVercel: variables set in VercelDashboard > EnvironmentVariables
# NEVER put production secrets in .env.production — it's in your repo
# === Example .env file setup ===
# .env (committed — public defaults):
NEXT_PUBLIC_APP_NAME=MyApp
NEXT_PUBLIC_CONTACT_EMAIL=hello@example.com
# .env.local (gitignored — secrets):
DATABASE_URL=postgres://localhost:5432/myapp
OPENAI_KEY=sk-...
SESSION_SECRET=local-dev-secret
# .env.production (committed — reference values, no real secrets):
DATABASE_URL=postgres://production:5432/myapp
NEXT_PUBLIC_API_URL=https://api.production.com
# === Check which file is overriding your value ===
grep -rn 'MY_VAR' .env* 2>/dev/null
.env.local Never Commits — But Set It Up for Team Safety
Production Insight
A team lost 2 hours debugging a production issue where a NEXT_PUBLIC_ variable was undefined. The variable was set in .env.local on the developer's machine (works locally), but never added to the Vercel environment settings. The production build had the variable undefined. The team learned: .env.local is for local development only — production values come from the hosting platform.
Rule: add all required environment variables to your deployment platform as part of your deployment checklist. Never rely on .env files for production values.
Key Takeaway
.env.local overrides all files in local development but has no effect in CI/CD.
Production env vars come from the hosting platform — not from .env files in the repo.
Use .env.example as a reference for required variables.
NEXT_PUBLIC_ Best Practices — What Belongs in the Prefix
NEXT_PUBLIC_ is designed for values that are intentionally public — configuration that the browser needs to know. These are values that would be hardcoded in your frontend code if you were writing vanilla HTML/JS.
Examples that belong in NEXT_PUBLIC_: Google Analytics measurement ID (G-XXXXXXXX), public API base URL (e.g., https://api.public.com), Sentry DSN (public, used for error reporting), feature flags for client-side toggling (e.g., NEXT_PUBLIC_FEATURE_NEW_CHECKOUT=true), and environment name for client-side logging (e.g., NEXT_PUBLIC_ENV=production).
Examples that must NEVER use NEXT_PUBLIC_: API keys (OpenAI, Stripe, AWS), database URLs, auth secrets and JWT signing keys, internal service URLs with no public access, admin credentials, and any value that grants access to a paid service.
If you're unsure, ask: 'Would I be comfortable posting this value on a public website?' If the answer is no, do not use NEXT_PUBLIC_.
public-vs-secret-cheat-sheet.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// ============================================// NEXT_PUBLIC_ Decision Guide// ============================================
/*
SAFEfor NEXT_PUBLIC_:
- GoogleAnalyticsID (G-XXXXXXXX)
- PublicAPI base URL
- SentryDSN (publicDSN)
- Featureflags (client-side)
- Environmentname (production/staging)
- Contact email
- App version/commit SHA
- Public keys for client-side crypto (not private keys)
NEVER use NEXT_PUBLIC_ for:
- APIkeys (OpenAI, Stripe, AWS, Twilio, etc.)
- Database connection strings
- Auth secrets and JWT signing keys
- Internal service URLs
- Admin credentials
- Any value with a monetary cost per use
- Any value that grants access to user data
*/
// ---- Good NEXT_PUBLIC_ usage ----exportfunctionGoogleAnalytics() {
return (
<script
async
src={`https://www.googletagmanager.com/gtag/js?id=${process.env.NEXT_PUBLIC_GA_ID}`}
/>
)
}
// ---- Bad NEXT_PUBLIC_ usage ----// DON'T: process.env.NEXT_PUBLIC_OPENAI_KEY// DON'T: process.env.NEXT_PUBLIC_STRIPE_KEY// DON'T: process.env.NEXT_PUBLIC_DATABASE_URL// ---- Pre-commit hook to catch NEXT_PUBLIC_ secrets ----// Add to .husky/pre-commit:// #!/bin/sh// grep -rn 'NEXT_PUBLIC_' .env* src/ app/ --include='*.ts' --include='*.tsx' \// | grep -iE '(key|secret|token|password|auth)' && \// echo 'ERROR: NEXT_PUBLIC_ contains a secret! Remove the prefix or rename the variable.' \// && exit 1 || exit 0
A NEXT_PUBLIC_ variable works fine in local development. The build succeeds, the app runs, and nothing seems wrong. The leak is invisible until someone checks the browser bundle. This is the most dangerous aspect of NEXT_PUBLIC_ — zero warnings until the damage is done.
Production Insight
The team that leaked the OpenAI key now has a pre-commit hook that scans for NEXT_PUBLIC_ variables with 'key', 'secret', 'token', or 'password' in their name. The hook blocks the commit and prints: 'ERROR: NEXT_PUBLIC_ contains a potential secret.' They also added a CI check that looks for API key patterns in the built bundle and fails the deployment if any are found.
Rule: add automated checks for NEXT_PUBLIC_ secrets at commit time and deploy time. Manual reviews miss these — automated scans do not.
Key Takeaway
NEXT_PUBLIC_ is for public configuration only — not secrets.
Add pre-commit hooks to catch NEXT_PUBLIC_ secrets.
Add CI checks that scan the built bundle for leaked API keys.
● Production incidentPOST-MORTEMseverity: high
NEXT_PUBLIC_ OpenAI Key Drained $8,000 in 4 Hours
Symptom
The OpenAI billing dashboard showed $8,000 in API charges within 4 hours of deployment. Usage patterns showed thousands of requests from random IP addresses generating content unrelated to the site. The API key had no usage restrictions — any IP could call OpenAI with it.
Assumption
The developer assumed all environment variables were server-side only, regardless of prefix. They used NEXT_PUBLIC_ because they needed the variable in a client component — the build succeeded and worked locally, so it seemed correct. The prefix just felt like a naming convention, not a security boundary.
Root cause
NEXT_PUBLIC_OPENAI_KEY was defined in .env.local and accessed via process.env.NEXT_PUBLIC_OPENAI_KEY in a client component. At build time, Next.js replaced process.env.NEXT_PUBLIC_OPENAI_KEY with the literal string value — it was baked into the JavaScript bundle. Any browser could read it by viewing the page source or checking the Network tab. A bot scraped the key from the bundle, then used it to call the OpenAI API directly. The key had no IP restrictions, no usage quotas beyond a $10,000/month limit, and no monitoring alerts below that threshold.
Fix
1) Regenerated the OpenAI API key (revoked the exposed one). 2) Moved the OpenAI API call to a Server Action — the key is accessed in a 'use server' function, not a client component. 3) Added IP restrictions to the new OpenAI key (only the server's IP can call OpenAI). 4) Set a $100 hard usage limit with alert at $50 on the OpenAI dashboard. 5) Added runtime environment variable validation using Zod that checks all required vars on server start — crashes early if a NEXT_PUBLIC_ secret is detected. 6) Added a pre-commit hook that scans for NEXT_PUBLIC_ variables with 'key', 'secret', 'token', or 'password' in their name and warns the developer.
Key lesson
NEXT_PUBLIC_ is NOT a naming convention — it's a security boundary. Variables with this prefix are inlined in the client bundle and publicly visible
Never put secrets (API keys, tokens, passwords, database URLs) in NEXT_PUBLIC_ — only public-facing values like GA tracker IDs or public API base URLs
Always set up usage limits and monitoring alerts on external API keys — even for server-side keys, a compromised server can leak them
Add a runtime env validation step that crashes on startup if required variables are missing or if NEXT_PUBLIC_ contains suspicious values
Production debug guideDiagnose missing, exposed, or misconfigured environment variables5 entries
Symptom · 01
process.env.VAR_NAME returns undefined in the browser
→
Fix
The variable either doesn't have the NEXT_PUBLIC_ prefix, or it wasn't set at build time. Un-prefixed variables are only available on the server. NEXT_PUBLIC_ variables must be present at build time (not at request time) to be inlined into the bundle.
Symptom · 02
process.env.NEXT_PUBLIC_VAR works locally but not in production
→
Fix
NEXT_PUBLIC_ variables are inlined at BUILD time. If your production build doesn't have the environment variable set in the build environment, the value is undefined. Set the variable in your CI/CD or hosting platform's environment settings.
Symptom · 03
Secrets visible in browser devtools
→
Fix
A variable with a sensitive value has the NEXT_PUBLIC_ prefix. Rename it to remove the prefix and move its usage to a server-only context (Server Action, API route, or server component that doesn't pass the value to the client).
Symptom · 04
Server Action can't access environment variable
→
Fix
Server Actions run on the server and have access to ALL environment variables, including un-prefixed ones. Check that the variable is set in the server environment (not just .env.local which is for local development only).
Symptom · 05
Runtime env validation error on deploy
→
Fix
Your Zod validation schema expects a variable that's not set in the production environment. Check your hosting platform's environment variable configuration. Add clear error messages to your validation schema so the missing variable name is obvious.
NEXT_PUBLIC_ inlines values at build time into the client bundle
never use it for secrets
2
Server-only env vars (no prefix) stay on the server
use Server Actions or API routes to access them from client code
3
Validate all environment variables at server startup with Zod
crash early, not on first user request
4
Defense in depth for API keys
IP restrictions, usage limits, least privilege, and automatic rotation
5
.env.local is for local development only
production values come from the hosting platform
6
Add automated pre-commit hooks and CI checks to prevent NEXT_PUBLIC_ secrets from reaching production
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01SENIOR
Explain what NEXT_PUBLIC_ does in Next.js and why it's dangerous for sec...
Q02SENIOR
How would you design a secure environment variable system for a Next.js ...
Q03SENIOR
What is the difference between build-time and runtime environment variab...
Q01 of 03SENIOR
Explain what NEXT_PUBLIC_ does in Next.js and why it's dangerous for secrets.
ANSWER
NEXT_PUBLIC_ is a build-time substitution directive. When Next.js encounters process.env.NEXT_PUBLIC_VAR, it replaces the entire expression with the literal value of the environment variable at build time — not at runtime. The value is inlined into the JavaScript bundle and sent to the browser. Anyone can read it via browser devtools, page source, or network inspection. It is dangerous for secrets because there is no warning or error — the build succeeds, the app works, and the secret is publicly visible. Secrets like API keys, database URLs, and auth tokens must never use NEXT_PUBLIC_. They should use un-prefixed environment variables accessed only in server-side code (Server Actions, API routes, Server Components).
Q02 of 03SENIOR
How would you design a secure environment variable system for a Next.js 16 application that uses multiple external APIs?
ANSWER
1) Use un-prefixed environment variables for all secrets — database URLs, API keys, auth secrets. 2) Create a lib/env.ts with a Zod schema that validates all required variables on server startup, crashing with a clear error message if any are missing. 3) Move all API calls that require secrets into Server Actions or API routes — client components never access secrets directly. 4) Add defense-in-depth: IP restrictions on each API key, usage limits and alerts on provider dashboards, and automatic key rotation via CI/CD. 5) Add a pre-commit hook that scans for NEXT_PUBLIC_ with 'key', 'secret', 'token', or 'password' in the variable name and blocks the commit. 6) Add CI/CD checks that scan the built .next bundle for common API key patterns and fail the deployment if any are found. 7) Never commit .env.local — use .env.example as a reference template.
Q03 of 03SENIOR
What is the difference between build-time and runtime environment variables in Next.js?
ANSWER
Build-time environment variables (NEXT_PUBLIC_ prefix) are replaced with their literal values during next build. The value is baked into the JavaScript bundle and cannot be changed without a rebuild. They are visible in client-side code. Runtime environment variables (no prefix) remain as process.env references in the source, resolved at request time on the server. They can be changed without a rebuild (environment variable updates on the hosting platform), but they are only accessible in server-side code. The trade-off: build-time vars are faster (no resolution needed) but less flexible and public; runtime vars are server-only but configurable without redeploys.
01
Explain what NEXT_PUBLIC_ does in Next.js and why it's dangerous for secrets.
SENIOR
02
How would you design a secure environment variable system for a Next.js 16 application that uses multiple external APIs?
SENIOR
03
What is the difference between build-time and runtime environment variables in Next.js?
SENIOR
FAQ · 5 QUESTIONS
Frequently Asked Questions
01
What happens if I put a secret in NEXT_PUBLIC_ and deploy?
The secret is inlined into your JavaScript bundle at build time. Anyone can view it by opening browser devtools (Sources tab -> search for the value), viewing the page source, or inspecting network responses. The only fix is to regenerate the secret, remove the NEXT_PUBLIC_ prefix, move its usage to a server context, and redeploy.
Was this helpful?
02
Why does my environment variable work in development but not in production?
Development uses .env.local and .env.development. Production uses the hosting platform's environment variables. If a variable is only defined in .env.local, it won't be available in production. Additionally, NEXT_PUBLIC_ variables are inlined at build time — they must be present in the build environment (CI/CD or hosting platform), not just at runtime.
Was this helpful?
03
How do I access server-only environment variables from a client component?
You cannot access server-only environment variables directly from a client component. Create a Server Action or API route that accesses the variable and returns the result, then call that from your client component. Alternatively, fetch the data in a Server Component and pass it as props to the Client Component.
Was this helpful?
04
What is the difference between process.env.VAR and process.env.NEXT_PUBLIC_VAR?
process.env.VAR (no prefix) is resolved at request time on the server. It never reaches the client bundle. process.env.NEXT_PUBLIC_VAR is replaced with the literal value at BUILD time — the value is baked into the client bundle and visible to all browsers. There is no runtime resolution for NEXT_PUBLIC_ variables.
Was this helpful?
05
Should I commit .env files to version control?
.env (default values) and .env.production (reference values without real secrets) can be committed. .env.local must never be committed — it contains local secrets and overrides. Add .env.local to .gitignore. Create a .env.example file with placeholder values as a template for new team members.