The most common Next.js security mistake I see in code reviews: a developer names a variable NEXT_PUBLIC_DATABASE_URL and wonders why their database password is visible in the browser's Network tab. The NEXT_PUBLIC_ prefix isn't just a naming convention — it controls whether the variable is inlined into the client-side JavaScript bundle at build time and served to every user who downloads the page. Getting this wrong exposes credentials to the public internet. Understanding Next.js's environment variable system is the first step toward production-grade security.
Next.js has two categories of environment variables: server-only (no prefix) and client-accessible (NEXT_PUBLIC_ prefix). Server-only variables are available in process.env on the server — in API routes, server components, getServerSideProps, and server actions. They are never included in the client bundle. NEXT_PUBLIC_ variables are inlined into the JavaScript bundle at build time — they're replaced with their literal values in the compiled JavaScript. The browser can read them from the JS source without any network request. Never put credentials, private keys, or secrets in NEXT_PUBLIC_ variables.
Public analytics IDs (GA4 measurement ID, Amplitude API key), public CDN base URLs, feature flags that are safe to expose, application environment (development/staging/production), and public OAuth client IDs are safe as NEXT_PUBLIC_ variables. Database connection strings, JWT secrets, private API keys, OAuth client secrets, encryption keys, and webhook signing secrets must be server-only variables. When in doubt, don't use NEXT_PUBLIC_. A server component or API route can always access process.env; a client component cannot.
Next.js loads environment files in this priority order (highest first): 1) process.env (already-set environment variables from the OS or deployment platform), 2) .env.local (always loaded, never committed to git), 3) .env.{development|test|production} (environment-specific), 4) .env (base file, committed to git for non-secret values). The .env.local file overrides .env, which is useful for local development: commit non-secret defaults in .env and put your local credentials in .env.local. The .env.local file should always be in .gitignore.
Next.js Environment Variable Security:
────────────────────────────────────────────────────────────
NEXT_PUBLIC_* variables Server-only variables
──────────────────────── ──────────────────────────
Inlined into JS bundle at build Only available in process.env
Visible to ALL users Never sent to browser
✓ GA4 Measurement ID ✓ DATABASE_URL
✓ Public CDN base URL ✓ JWT_SECRET
✓ Feature flags (safe ones) ✓ STRIPE_SECRET_KEY
✗ NEVER database credentials ✓ SENDGRID_API_KEY
✗ NEVER private API keys ✓ WEBHOOK_SIGNING_KEY
File priority (highest → lowest):
process.env (OS/deployment platform) [always wins]
.env.local (personal, never commit) [local override]
.env.production / .env.development [environment-specific]
.env (committed, non-secrets) [base defaults]
Example .env (safe to commit):
NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX
NEXT_PUBLIC_APP_URL=https://matthewswong.com
NODE_ENV=production
Example .env.local (NEVER commit — .gitignore):
DATABASE_URL=postgresql://user:secret@localhost/mydb
JWT_SECRET=my-local-dev-secret-change-in-prodFrom my experience managing environment variables across development, staging, and production: validate all required environment variables at application startup, not at runtime when they're first used. I use the `t3-oss/env-nextjs` package (or a custom Zod schema) to validate and type environment variables at build time and on app start. If a required variable is missing in production, the app fails immediately with a clear error message — rather than failing silently hours later when a code path that needs the variable is first hit.
Storing secrets as environment variables is the right approach for most applications, but where those environment variables come from matters. For Vercel deployments, use Vercel's encrypted environment variables — they're stored encrypted, available to builds and serverless functions, and can be scoped by environment (development/preview/production). For self-hosted deployments, consider Doppler (secrets manager with environment sync), HashiCorp Vault, or AWS Secrets Manager for anything above a personal project. The key feature to look for: secrets rotation without redeployment.
Next.js Server Actions run on the server but are called from the client via POST requests to a server endpoint. The function body isn't exposed to the client — but be careful not to return secret values from a Server Action. If a Server Action returns `process.env.INTERNAL_API_KEY`, the client receives that value in the response. Apply the same 'minimum disclosure' principle to Server Action return values as to API route responses: only return what the client needs, never server-side secrets or raw database records containing sensitive fields.
// env.ts — type-safe environment variable validation
import { createEnv } from "@t3-oss/env-nextjs"
import { z } from "zod"
export const env = createEnv({
// Server-only variables (never exposed to browser)
server: {
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32, "JWT secret must be 32+ chars"),
SENDGRID_API_KEY: z.string().startsWith("SG."),
NODE_ENV: z.enum(["development", "test", "production"]),
REVALIDATION_SECRET: z.string().min(16),
},
// Client-accessible variables (safe to expose)
client: {
NEXT_PUBLIC_APP_URL: z.string().url(),
NEXT_PUBLIC_GA_ID: z.string().regex(/^G-[A-Z0-9]+$/).optional(),
},
// Map process.env keys to validation schema
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
JWT_SECRET: process.env.JWT_SECRET,
SENDGRID_API_KEY: process.env.SENDGRID_API_KEY,
NODE_ENV: process.env.NODE_ENV,
REVALIDATION_SECRET: process.env.REVALIDATION_SECRET,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
NEXT_PUBLIC_GA_ID: process.env.NEXT_PUBLIC_GA_ID,
},
})
// Usage — TypeScript catches wrong variable access at compile time:
import { env } from "@/env"
// In a server component or API route:
const db = createPool({ connectionString: env.DATABASE_URL }) // ✓ type-safe
// In a client component: TypeScript error if you access server vars!
const url = env.NEXT_PUBLIC_APP_URL // ✓ allowed in client
// const secret = env.JWT_SECRET // ✗ TypeScript ERROR in client code
// .env.example (commit this — documents required vars)
DATABASE_URL=postgresql://user:pass@localhost/mydb
JWT_SECRET=your-32-char-minimum-secret-here
SENDGRID_API_KEY=SG.your-key-here
NEXT_PUBLIC_APP_URL=http://localhost:3000TypeScript's `process.env` types all variables as `string | undefined`. In production code, you'll frequently write `process.env.DATABASE_URL!` to assert the variable is defined — which silently fails if the variable is actually missing. Use a Zod schema to validate and transform environment variables at startup, then export the validated object as `env`. The `@t3-oss/env-nextjs` package wraps Zod for Next.js with separate schemas for server and client variables, preventing client code from accidentally accessing server-only variables (TypeScript errors at compile time).
If you accidentally commit a .env file containing real credentials, removing it in the next commit doesn't fix the problem — the secret is still in git history. Anyone who clones the repository can run `git log --diff-filter=D --summary` and find the deleted file, then `git show <commit>:<filename>` to read the content. The fix: rotate the exposed credentials immediately (invalidate the old ones, generate new ones), then use BFG Repo Cleaner or `git filter-repo` to rewrite history and remove the file from all commits. Also enable secret scanning on your GitHub repository to catch future accidents.
For a project with local development, staging, and production environments, I use: .env (committed, non-secret defaults — base URLs, feature flags, public IDs), .env.local (not committed, developer's personal credentials for local development), Vercel environment variables (encrypted, per-environment: development, preview, production). The staging environment uses the same Vercel environment variable setup as production but with staging service credentials. This means a staging deploy is as close as possible to a production deploy — no 'it works in staging but not production' from environment differences.
Run a periodic audit of your environment variables to catch: NEXT_PUBLIC_ variables that contain sensitive data, unused variables that clutter configuration, missing variables that could cause runtime failures, and duplicate variables across environments that should be different (e.g., using the same database for staging and production). I run `grep -r 'NEXT_PUBLIC_' src/ --include='*.ts' --include='*.tsx'` to find all usages of public variables and manually verify each one is appropriate to expose. Also check your Vercel/deployment platform's variable list against your .env.example to catch missing production variables.