Environment Variables Management in Next.js Applications
Complete guide to managing environment variables in Next.js including NEXT_PUBLIC_, server-only, and deployment-specific configuration
Environment Variables Management in Next.js Applications
Introduction
Environment variables are the standard way to configure applications for different environments without hardcoding values. Next.js has specific rules about how environment variables are loaded, which are exposed to the client, and how they behave during build versus runtime.
A single misconfigured environment variable can expose secrets to the client bundle or cause silent failures in production. This post covers the complete environment variable management strategy for Next.js applications.
Environment
- OS: Windows 11
- Node.js: v20.10.0
- Next.js: 14.0.4
- Deployment: Vercel + Docker
Problem
Common environment variable issues:
Error: Environment variable 'DATABASE_URL' references a public prefix
('NEXT_PUBLIC_DATABASE_URL') but is not accessible on the client side.Warning: env: 'API_SECRET' is not defined in '.env'Error: Accessing the NEXT_PUBLIC_ env variable at build time is not supported.
Use window.env or a runtime config instead.The most dangerous issue: accidentally exposing server-side secrets to the browser:
// This EXPOSES the database URL to the client bundle
console.log(process.env.DATABASE_URL) // Works in Server Component
// But if used in a Client Component, it shows "undefined"
// However, it IS included in the build output if imported incorrectlyAnalysis
Next.js environment variable rules:
1. NEXT_PUBLIC_ prefix. Variables with this prefix are embedded into the client bundle at build time. They are visible to anyone who views the source code.
2. No prefix. Variables without NEXT_PUBLIC_ are only available in Server Components, API routes, and Server Actions. They are never sent to the client.
3. .env file loading order. Next.js loads .env, .env.local, .env.development, .env.production in a specific order.
4. Build-time vs runtime. Most variables are inlined at build time. Only variables accessed through process.env in Server Components are available at runtime.
Solution
Fix 1: Proper .env file structure
# .env (committed to git - shared defaults)
DATABASE_URL=postgresql://localhost:5432/mydb
NEXT_PUBLIC_APP_URL=http://localhost:3000
NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX
# .env.local (NOT committed - overrides .env)
DATABASE_URL=postgresql://prod-server:5432/mydb
API_SECRET=super-secret-key
NEXT_PUBLIC_API_URL=https://api.example.com
# .env.development (development-specific)
DATABASE_URL=postgresql://localhost:5432/mydb_dev
DEBUG=true
# .env.production (production-specific)
DATABASE_URL=postgresql://prod-server:5432/mydb_prod
DEBUG=falseFix 2: Validate environment variables at build time
// lib/env.ts
const requiredServerVars = [
'DATABASE_URL',
'API_SECRET',
'NEXTAUTH_SECRET',
] as const
const requiredClientVars = [
'NEXT_PUBLIC_API_URL',
'NEXT_PUBLIC_GA_ID',
] as const
type ServerEnv = (typeof requiredServerVars)[number]
type ClientEnv = (typeof requiredClientVars)[number]
function getEnvVar(name: ServerEnv): string
function getEnvVar(name: ClientEnv): string
function getEnvVar(name: string): string | undefined {
return process.env[name]
}
function validateEnv() {
const missing: string[] = []
for (const key of requiredServerVars) {
if (!process.env[key]) {
missing.push(key)
}
}
if (missing.length > 0) {
throw new Error(
`Missing required environment variables: ${missing.join(', ')}`
)
}
}
// Validate on import
validateEnv()
export { getEnvVar }// app/api/config/route.ts
import { NextResponse } from 'next/server'
export async function GET() {
return NextResponse.json({
apiUrl: process.env.NEXT_PUBLIC_API_URL,
// Never expose:
// - DATABASE_URL
// - API_SECRET
// - NEXTAUTH_SECRET
})
}Fix 3: Docker deployment environment variables
# Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
# Build with environment variables
ARG NEXT_PUBLIC_API_URL
ARG DATABASE_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV DATABASE_URL=$DATABASE_URL
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
# Runtime environment variables
ENV NODE_ENV=production
ENV DATABASE_URL=${DATABASE_URL}
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]# docker-compose.yml
services:
app:
build: .
environment:
- DATABASE_URL=postgresql://db:5432/mydb
- API_SECRET=${API_SECRET}
- NEXT_PUBLIC_API_URL=https://api.example.com
ports:
- "3000:3000"Fix 4: Vercel deployment configuration
// vercel.json
{
"env": {
"DATABASE_URL": "@database-url",
"API_SECRET": "@api-secret",
"NEXT_PUBLIC_API_URL": "https://api.example.com"
}
}Lessons Learned
Never prefix secrets with
NEXT_PUBLIC_. This embeds the value into the client-side JavaScript bundle, visible to anyone.Validate environment variables at startup. Fail fast with a clear error message rather than failing silently in production.
Use
.env.examplein version control. Document all required variables without including actual values.Different variables for build vs runtime. Variables needed during
next build(likeNEXT_PUBLIC_*) must be available at build time. Variables used in Server Components can be runtime-only.Use a secrets manager for production. Vercel Secrets, AWS Secrets Manager, or HashiCorp Vault are preferable to
.envfiles in production.
This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.