Home JavaScript NEXT_PUBLIC_ Prefixed API Key — $8,000 OpenAI Bill Before Anyone Noticed
Intermediate 3 min · July 12, 2026
Environment Variables and Configuration in Next.js 16

NEXT_PUBLIC_ Prefixed API Key — $8,000 OpenAI Bill Before Anyone Noticed

A NEXT_PUBLIC_ prefixed OpenAI API key was exposed in browser bundles, scraped by a bot, and racked up $8,000 in charges.

N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Written from production experience, not tutorials.

Follow
Production
production tested
July 12, 2026
last updated
1,787
articles · all by Naren
Before you start⏱ 15 min
  • React
  • Node.js 18+
  • Next.js basics
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • 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
ChromeFirefoxSafariEdge

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'

# === Check if secrets are exposed in the built bundle ===
# Look for 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.
nextjs-environment-variables-config THECODEFORGE.IO Secure API Key Architecture in Next.js Layered approach to protect external service credentials Client Layer (Browser) No env vars | Public data only API Route Layer (Serverless) Server-only env vars | Runtime validation with Zod Environment Variable Layer .env.local | .env.production | .env Secret Management Layer Vercel Environment Variables | AWS Secrets Manager External API Layer OpenAI API | Rate limiting | Usage alerts THECODEFORGE.IO
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'

function WrongComponent() {
  // process.env.OPENAI_KEY is undefined in the browser
  return null
}

// ---- CORRECT: Server Action handles the secret ----

// File: app/actions/generate.ts
'use server'

import OpenAI from 'openai'

export async function generateDescription(
  prompt: string
): Promise<string> {
  // This runs on the server — OPENAI_KEY is available here
  const openai = new OpenAI({
    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)
export default async function ProductPage({
  params,
}: {
  params: { slug: string }
}) {
  // Server Component has access to all env vars
  const apiUrl = process.env.API_URL
  const product = await fetch(`${apiUrl}/products/${params.slug}`).then(
    r => r.json()
  )

  return <ProductDetails product={product} />
}

// ---- CORRECT: lib/env.ts validates at startup ----

export function getServerConfig() {
  return {
    openaiKey: process.env.OPENAI_KEY,
    databaseUrl: process.env.DATABASE_URL,
    redisUrl: process.env.REDIS_URL,
  }
}
Try it live
Server = Trusted, Client = Untrusted
  • 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(),
})

export type Env = 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)
}

export const env: Env = parsed.data

// ---- Use in your app ----

import { env } from '@/lib/env'

// Type-safe, validated access
const db = new Pool({ connectionString: env.DATABASE_URL })
Try it live
Validate at Startup, Not at First Use
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 Vars Why prefix choice determines security posture NEXT_PUBLIC_ Prefix Server-Only Env Vars Exposure in client bundle Inlined at build time, visible in browse Never sent to client, stays on server Use case Public values (e.g., API base URL) Secret keys (e.g., OpenAI API key) Runtime validation Not typically validated, assumed public Validated with Zod before use Security risk High — key can be extracted by anyone Low — only accessible in server context Best practice Avoid for any sensitive data Always use for API keys and secrets THECODEFORGE.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) ----

export function verifyServerIP(): boolean {
  // This runs on the server — check if we're calling from the expected IP
  // Implementation depends on your hosting platform
  return true
}

// ---- Usage monitoring wrapper ----

interface APIUsage {
  endpoint: string
  tokens: number
  cost: number
  timestamp: number
}

async function trackAPIUsage(usage: APIUsage) {
  // Log usage to your monitoring system
  console.log('API_USAGE', usage)

  // Check against budget
  const dailyCost = await getDailyCost()
  const DAILY_LIMIT = 50 // $50 daily limit

  if (dailyCost + usage.cost > DAILY_LIMIT) {
    throw new Error(
      'API budget limit reached. Please try again tomorrow.'
    )
  }
}

// ---- Wrapped OpenAI client with budget checks ----

import OpenAI from 'openai'

const openai = new OpenAI({ apiKey: env.OPENAI_KEY })

export async function generateWithBudget(
  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 error
  trackAPIUsage(usage).catch(console.error)

  return response.choices[0]?.message?.content || ''
}
Try it live
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 File Resolution Order
# ============================================

# === File hierarchy (highest priority first) ===
# 1. .env.local              — Local overrides, NEVER committed
# 2. .env.development        — Development-specific
# 3. .env.production         — Production defaults (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 ===
# On Vercel: variables set in Vercel Dashboard > Environment Variables
# 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
// ============================================

/*
SAFE for NEXT_PUBLIC_:
  - Google Analytics ID (G-XXXXXXXX)
  - Public API base URL
  - Sentry DSN (public DSN)
  - Feature flags (client-side)
  - Environment name (production/staging)
  - Contact email
  - App version/commit SHA
  - Public keys for client-side crypto (not private keys)

NEVER use NEXT_PUBLIC_ for:
  - API keys (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 ----

export function GoogleAnalytics() {
  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
Try it live
The 'It Works Locally' Trap
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.
★ Environment Variables Quick Debug ReferenceFast commands for diagnosing environment variable issues
Variable undefined in production
Immediate action
Check if the env var is set in the deployment environment
Commands
vercel env ls (if on Vercel) or check hosting dashboard
Check .env.production or .env.local for local vs production differences
Fix now
Set the env var in the hosting platform's environment settings, then redeploy
Secret leaked to browser bundle+
Immediate action
Check if the variable uses NEXT_PUBLIC_ prefix
Commands
grep -rn 'NEXT_PUBLIC_' .env* app/ 2>/dev/null | grep -i 'key\|secret\|token\|password\|api'
Check the built JS bundle: grep -r 'sk-' .next/static/ 2>/dev/null | head -5
Fix now
Remove NEXT_PUBLIC_ prefix from the secret, regenerate the key, redeploy
Runtime validation fails on deploy+
Immediate action
Read the validation error message — it lists the missing variable name
Commands
Check deployment logs for the validation error
Check your lib/env.ts validation schema for required variables
Fix now
Add the missing environment variable to the production environment, then redeploy
Client component can't access server-only env var+
Immediate action
The variable is server-only (no NEXT_PUBLIC_) and cannot be accessed from client code
Commands
Check the component file for 'use client' directive
grep -rn "process.env" app/ --include='*.tsx' --include='*.ts' | grep -v NEXT_PUBLIC | grep -v node_modules
Fix now
Move the env var access to a Server Action or API route, pass the result as props
⚙ Quick Reference
6 commands from this guide
FileCommand / CodePurpose
check-public-vars.shgrep -rn 'NEXT_PUBLIC_' .env* 2>/dev/null | grep -i 'key\|secret\|token\|passwor...The NEXT_PUBLIC_ Prefix
server-only-env.ts'use client'Server-Only Secrets
env-validation.tsconst envSchema = z.object({Runtime Environment Variable Validation with Zod
api-key-security.tsexport function verifyServerIP(): boolean {Security Measures for External API Keys
env-resolution.shNEXT_PUBLIC_APP_NAME=MyApp.env Files
public-vs-secret-cheat-sheet.ts/*NEXT_PUBLIC_ Best Practices

Key takeaways

1
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).
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What happens if I put a secret in NEXT_PUBLIC_ and deploy?
02
Why does my environment variable work in development but not in production?
03
How do I access server-only environment variables from a client component?
04
What is the difference between process.env.VAR and process.env.NEXT_PUBLIC_VAR?
05
Should I commit .env files to version control?
N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Written from production experience, not tutorials.

Follow
Verified
production tested
July 12, 2026
last updated
1,787
articles · all by Naren
🔥

That's Next.js. Mark it forged?

3 min read · try the examples if you haven't

Previous
Server Actions Deep Dive: Forms, Validation, and Progressive Enhancement
38 / 56 · Next.js
Next
Testing Next.js Applications: Unit, Integration, and E2E Testing