Back to Blog

Next.js Environment Variables: .env Hierarchy, Runtime vs Build-Time, Secret Injection, and Vercel

Master Next.js environment variables. Covers .env file hierarchy and precedence, NEXT_PUBLIC_ prefix for client exposure, runtime vs build-time variables, secret injection in Docker and AWS ECS, Vercel environment management, and common mistakes.

Viprasol Tech Team
May 2, 2027
11 min read

Environment variable mistakes are one of the most common causes of Next.js production incidents: secrets accidentally exposed to the browser via NEXT_PUBLIC_, API keys baked into Docker images at build time instead of injected at runtime, and .env files committed to version control. Getting env vars right is foundational security hygiene.

This guide covers the full environment variable system in Next.js โ€” from file precedence to runtime injection in production.

File Precedence (Lowest to Highest Priority)

.env                    # Base defaults โ€” committed to git
.env.local              # Local overrides โ€” NEVER commit (in .gitignore)
.env.development        # Development-only โ€” committed to git
.env.development.local  # Local dev overrides โ€” NEVER commit
.env.production         # Production-only โ€” committed to git (no secrets!)
.env.production.local   # Local production overrides โ€” NEVER commit
.env.test               # Test environment

Next.js loads files in this order; later files override earlier ones. The NODE_ENV determines which environment-specific files are loaded.

# .gitignore โ€” always exclude local files
.env*.local
.env.production  # Exclude if it contains any real values

The NEXT_PUBLIC_ Rule

// Variables WITHOUT NEXT_PUBLIC_: server-only (never sent to browser)
DATABASE_URL=postgresql://...      // โœ… Server only
STRIPE_SECRET_KEY=sk_live_...     // โœ… Server only
OPENAI_API_KEY=sk-...             // โœ… Server only

// Variables WITH NEXT_PUBLIC_: embedded in JavaScript bundle at BUILD TIME
NEXT_PUBLIC_APP_URL=https://app.viprasol.com  // โœ… Safe โ€” not a secret
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...  // โœ… Safe โ€” publishable key
NEXT_PUBLIC_POSTHOG_KEY=phc_...   // โœ… Safe โ€” client analytics key

// โŒ NEVER do this:
NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_...  // Exposed to every browser visitor!
NEXT_PUBLIC_DATABASE_URL=postgresql://...  // Exposed to every browser visitor!

Critical: NEXT_PUBLIC_ variables are replaced with their literal values at build time by Next.js. If you change them after building, you must rebuild. They cannot be changed at runtime without a new deployment.

๐ŸŒ Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different โ€” senior engineers only, direct Slack access, and a 5.0โ˜… Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript โ€” production-grade stack
  • Fixed-price contracts โ€” no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

TypeScript: Validate Env at Startup

// lib/env.ts โ€” validate all required env vars at module load time
// This crashes the process immediately if config is wrong,
// rather than failing silently later

import { z } from "zod";

const ServerEnvSchema = z.object({
  // Database
  DATABASE_URL:           z.string().url(),

  // Auth
  NEXTAUTH_URL:           z.string().url(),
  NEXTAUTH_SECRET:        z.string().min(32),

  // Stripe
  STRIPE_SECRET_KEY:      z.string().startsWith("sk_"),
  STRIPE_WEBHOOK_SECRET:  z.string().startsWith("whsec_"),

  // AWS
  AWS_REGION:             z.string().default("us-east-1"),
  AWS_ACCESS_KEY_ID:      z.string().optional(),
  AWS_SECRET_ACCESS_KEY:  z.string().optional(),
  S3_BUCKET_NAME:         z.string(),

  // Email
  RESEND_API_KEY:         z.string().startsWith("re_"),

  // App
  NODE_ENV:               z.enum(["development", "production", "test"]),
});

const ClientEnvSchema = z.object({
  NEXT_PUBLIC_APP_URL:                    z.string().url(),
  NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY:     z.string().startsWith("pk_"),
  NEXT_PUBLIC_POSTHOG_KEY:               z.string().optional(),
  NEXT_PUBLIC_POSTHOG_HOST:              z.string().url().optional(),
});

// Validate server env (runs only on server โ€” process.env available)
function validateServerEnv() {
  const result = ServerEnvSchema.safeParse(process.env);
  if (!result.success) {
    console.error("โŒ Invalid server environment variables:");
    console.error(result.error.flatten().fieldErrors);
    throw new Error("Invalid environment configuration โ€” check your .env file");
  }
  return result.data;
}

// Validate client env (safe to call anywhere)
function validateClientEnv() {
  const result = ClientEnvSchema.safeParse({
    NEXT_PUBLIC_APP_URL:                process.env.NEXT_PUBLIC_APP_URL,
    NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
    NEXT_PUBLIC_POSTHOG_KEY:           process.env.NEXT_PUBLIC_POSTHOG_KEY,
    NEXT_PUBLIC_POSTHOG_HOST:          process.env.NEXT_PUBLIC_POSTHOG_HOST,
  });
  if (!result.success) {
    throw new Error(`Invalid client env: ${JSON.stringify(result.error.flatten().fieldErrors)}`);
  }
  return result.data;
}

// Export validated, typed env objects
export const serverEnv = typeof window === "undefined"
  ? validateServerEnv()
  : ({} as ReturnType<typeof validateServerEnv>);

export const clientEnv = validateClientEnv();

// Usage:
// import { serverEnv } from "@/lib/env";
// const stripe = new Stripe(serverEnv.STRIPE_SECRET_KEY);

.env Files Structure

# .env โ€” safe defaults, committed to git
NODE_ENV=development
NEXT_PUBLIC_APP_URL=http://localhost:3000

# .env.local โ€” your personal secrets, NEVER commit
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/myapp_dev
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=dev-secret-at-least-32-characters-long
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
AWS_REGION=us-east-1
S3_BUCKET_NAME=myapp-dev-uploads
RESEND_API_KEY=re_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...

# .env.production โ€” production non-secrets only, committed to git
NODE_ENV=production
NEXT_PUBLIC_APP_URL=https://app.viprasol.com
NEXT_PUBLIC_POSTHOG_HOST=https://app.posthog.com

๐Ÿš€ Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4โ€“8 weeks, full platforms in 3โ€“5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

Runtime Variables in Docker/ECS

For containerized deployments, never bake secrets into the Docker image. Inject them at runtime:

# Dockerfile โ€” no ENV instructions for secrets
FROM node:22-alpine AS base

# Build stage: only needs NEXT_PUBLIC_ vars (baked at build time)
FROM base AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .

# Build-time NEXT_PUBLIC_ vars are passed as build args
ARG NEXT_PUBLIC_APP_URL
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY

RUN npm run build

# Runtime stage: server env vars injected at container start, NOT baked in
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production

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"]
# DATABASE_URL, STRIPE_SECRET_KEY, etc. come from ECS task environment
# They are NOT in the image โ€” they're injected at task start
# terraform/ecs-task.tf โ€” inject secrets at runtime
resource "aws_ecs_task_definition" "app" {
  family = var.app_name

  container_definitions = jsonencode([{
    name  = "app"
    image = "${var.ecr_repo}:${var.image_tag}"

    environment = [
      { name = "NODE_ENV",             value = "production" },
      { name = "AWS_REGION",           value = var.aws_region },
      # NEXT_PUBLIC_ vars are already baked into the image โ€” no need to pass again
    ]

    secrets = [
      # Fetched from Secrets Manager at container start (not baked into image)
      { name = "DATABASE_URL",          valueFrom = "${var.secrets_arn}:DATABASE_URL::" },
      { name = "NEXTAUTH_SECRET",       valueFrom = "${var.secrets_arn}:NEXTAUTH_SECRET::" },
      { name = "STRIPE_SECRET_KEY",     valueFrom = "${var.secrets_arn}:STRIPE_SECRET_KEY::" },
      { name = "STRIPE_WEBHOOK_SECRET", valueFrom = "${var.secrets_arn}:STRIPE_WEBHOOK_SECRET::" },
      { name = "RESEND_API_KEY",        valueFrom = "${var.secrets_arn}:RESEND_API_KEY::" },
    ]
  }])
}

Vercel Environment Management

# Vercel CLI: set environment variables per environment
vercel env add DATABASE_URL production         # Production only
vercel env add DATABASE_URL preview            # Preview deployments
vercel env add DATABASE_URL development        # Local dev via vercel dev

# Pull env vars for local development (safe alternative to .env.local)
vercel env pull .env.local

# List all env vars (values are masked)
vercel env ls

# Remove a variable
vercel env rm OLD_API_KEY production

For Vercel's Edge Runtime (middleware, edge functions), only NEXT_PUBLIC_ vars and variables explicitly marked for Edge are available. Server-only vars are NOT available in Edge:

// middleware.ts โ€” Edge Runtime
export const runtime = "edge";

// โœ… Available in Edge
const appUrl = process.env.NEXT_PUBLIC_APP_URL;

// โŒ NOT available in Edge (only in Node.js runtime)
// const db = process.env.DATABASE_URL; // undefined at edge

Server Actions and env

// app/actions/send-email.ts โ€” Server Action
"use server";

// process.env is available in Server Actions (Node.js runtime)
import { serverEnv } from "@/lib/env";

export async function sendWelcomeEmail(userId: string) {
  // serverEnv.RESEND_API_KEY is safely available here
  const resend = new Resend(serverEnv.RESEND_API_KEY);
  // ...
}

Common Mistakes

// โŒ MISTAKE 1: Reading server env in a Client Component
"use client";
function PaymentForm() {
  // process.env.STRIPE_SECRET_KEY is undefined in browser
  // And if it were NEXT_PUBLIC_, it would be exposed!
  const key = process.env.STRIPE_SECRET_KEY; // undefined
}

// โœ… FIX: Call a Server Action or API route that uses the key server-side

// โŒ MISTAKE 2: Dynamic NEXT_PUBLIC_ variable names
const key = `NEXT_PUBLIC_${name}`; // Won't work โ€” Next.js replaces statically at build time
process.env[key]; // undefined

// โœ… FIX: Reference NEXT_PUBLIC_ vars by their full literal name

// โŒ MISTAKE 3: Checking NODE_ENV at runtime in Edge
// Edge runtime may not have NODE_ENV set as expected
if (process.env.NODE_ENV === "production") { /* ... */ }
// Use NEXT_PUBLIC_APP_ENV or check the hostname instead at Edge

// โŒ MISTAKE 4: .env.production with real secrets committed to git
# .env.production
STRIPE_SECRET_KEY=sk_live_abc123  # โŒ Git history = permanent exposure

// โœ… FIX: Use Vercel env vars, AWS Secrets Manager, or inject via CI/CD secrets

Cost and Timeline

Environment variable setup is a one-time configuration task:

TaskTime
.env file hierarchy setup + .gitignore30 min
Zod env validation schema1โ€“2 hours
Docker build-arg vs runtime separation1 hour
ECS Secrets Manager injection (Terraform)2โ€“3 hours
Vercel env management workflow30 min

See Also


Working With Viprasol

Environment variable mistakes are silent until they're catastrophic โ€” either a secret exposed in a browser bundle, or a missing variable causing a cryptic runtime error in production. Our team sets up typed, validated env schemas with Zod, cleanly separates build-time and runtime variables in Docker/ECS deployments, and manages secrets through AWS Secrets Manager rather than baking them into images.

What we deliver:

  • Zod server + client env validation schema (crashes fast on misconfiguration)
  • .env hierarchy with annotated comments and .gitignore setup
  • Dockerfile with build-arg for NEXT_PUBLIC_ and runtime injection for secrets
  • Terraform ECS task definition with Secrets Manager secrets array
  • Vercel env management workflow for preview/production/development

Talk to our team about your environment and secrets management โ†’

Or explore our web development services.

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms โ€” we build it all with Next.js and React.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse โ‰ฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ€” from architecture through deployment.