BETAWe're in beta! If you spot a bug, let us know.
·12 min·By Keyway Team

Environment Variables in Next.js: The Complete Guide

Everything you need to know about environment variables in Next.js - how they work, security implications, validation patterns, and team workflows.

Environment variables in Next.js are deceptively simple on the surface but hide important nuances that can lead to security issues or frustrating bugs. This guide covers everything from the basics to advanced patterns.

How Next.js Loads Environment Variables

Next.js automatically loads environment variables from several files, in this order of priority (later files override earlier ones):

.env                # Base defaults (all environments)
.env.local          # Local overrides (git-ignored)
.env.development    # Development mode only
.env.development.local
.env.production     # Production mode only
.env.production.local
.env.test           # Test mode only
.env.test.local

Important: .env.local is always ignored during next build in test mode to ensure reproducible test results.

Which file should you use?

FileCommitted to git?Use case
.envYesDefault values, non-sensitive
.env.localNoSecrets, local overrides
.env.developmentYesDev-specific defaults
.env.productionYesProd-specific defaults
.env.*.localNoEnv-specific secrets

A typical setup:

# .env (committed)
NEXT_PUBLIC_APP_URL=http://localhost:3000
LOG_LEVEL=debug

# .env.local (git-ignored, contains your secrets)
DATABASE_URL=postgres://user:password@localhost/mydb
STRIPE_SECRET_KEY=sk_test_...

The NEXT_PUBLIC_ Prefix: Understanding the Security Model

This is the most important concept to understand. Next.js has two types of environment variables:

Server-only variables (no prefix)

DATABASE_URL=postgres://...
STRIPE_SECRET_KEY=sk_live_...
JWT_SECRET=super-secret-key

These are only available on the server:

  • Server Components
  • API Routes
  • Middleware
  • getServerSideProps / getStaticProps

Attempting to access them in client code returns undefined:

// app/page.tsx (Client Component)
'use client'

export default function Page() {
  // Always undefined - this is intentional!
  console.log(process.env.DATABASE_URL)
  return <div>...</div>
}

Public variables (NEXT_PUBLIC_ prefix)

NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_ANALYTICS_ID=G-XXXXXXX

These are inlined into your JavaScript bundle at build time:

// This works in both server and client code
const apiUrl = process.env.NEXT_PUBLIC_API_URL

Critical security implication: Anything with NEXT_PUBLIC_ is visible to anyone who views your page source. Never put secrets here.

The inlining mechanism

When Next.js builds your app, it literally replaces process.env.NEXT_PUBLIC_* with the actual values:

// Your code
const url = process.env.NEXT_PUBLIC_API_URL

// After build (in the browser bundle)
const url = "https://api.example.com"

This means:

  1. You can't change these values without rebuilding
  2. Dynamic access doesn't work: process.env[varName] returns undefined
  3. The values are frozen at build time

Runtime vs Build-time Variables

Understanding when variables are read is crucial for debugging:

Variable typeWhen it's readCan change without rebuild?
Server-onlyRuntimeYes
NEXT_PUBLIC_Build timeNo

Practical implications

Scenario: You update NEXT_PUBLIC_API_URL in Vercel and redeploy.

  • If you just restarted the server: Old value (it was inlined at build)
  • If you triggered a new build: New value

Scenario: You update DATABASE_URL in Vercel and redeploy.

  • Server immediately uses the new value (read at runtime)

When you need runtime public variables

Sometimes you need client-accessible config that can change without rebuilding. Options:

1. Server-render the config:

// app/layout.tsx
export default function RootLayout({ children }) {
  return (
    <html>
      <head>
        <script
          dangerouslySetInnerHTML={{
            __html: `window.ENV = ${JSON.stringify({
              apiUrl: process.env.API_URL,
            })}`,
          }}
        />
      </head>
      <body>{children}</body>
    </html>
  )
}

2. Use a config endpoint:

// app/api/config/route.ts
export async function GET() {
  return Response.json({
    apiUrl: process.env.API_URL,
    features: process.env.FEATURE_FLAGS?.split(','),
  })
}

3. Use Next.js publicRuntimeConfig (Pages Router only):

// next.config.js
module.exports = {
  publicRuntimeConfig: {
    apiUrl: process.env.API_URL,
  },
}

Validating Environment Variables

Never trust that your environment variables exist or are valid. Validate them at startup.

Basic validation with Zod

// lib/env.ts
import { z } from 'zod'

const envSchema = z.object({
  // Server-only
  DATABASE_URL: z.string().url(),
  STRIPE_SECRET_KEY: z.string().startsWith('sk_'),

  // Optional with default
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),

  // Public
  NEXT_PUBLIC_APP_URL: z.string().url(),
})

export const env = envSchema.parse(process.env)

// TypeScript knows the exact shape
env.DATABASE_URL // string
env.LOG_LEVEL    // 'debug' | 'info' | 'warn' | 'error'

Using @t3-oss/env-nextjs (recommended)

This library is specifically designed for Next.js and handles the client/server split:

// env.ts
import { createEnv } from '@t3-oss/env-nextjs'
import { z } from 'zod'

export const env = createEnv({
  server: {
    DATABASE_URL: z.string().url(),
    STRIPE_SECRET_KEY: z.string().min(1),
  },
  client: {
    NEXT_PUBLIC_APP_URL: z.string().url(),
  },
  runtimeEnv: {
    DATABASE_URL: process.env.DATABASE_URL,
    STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
    NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
  },
})

Benefits:

  • Build fails if variables are missing
  • TypeScript autocompletion
  • Runtime errors if you access server vars on client
  • Clear documentation of required variables

Team Workflows: Sharing Secrets Safely

The classic problem: a new developer joins and needs the .env file.

Option 1: Manual sharing (not recommended)

The "just send it on Slack" approach. Problems:

  • Secrets in chat history forever
  • No access control after sharing
  • No audit trail
  • Version drift between team members

Option 2: Encrypted .env in repo

Tools like dotenvx or git-crypt encrypt your .env files:

# Encrypt with dotenvx
dotenvx encrypt

# Commit the encrypted file
git add .env.vault
git commit -m "Update secrets"

# Team member decrypts
dotenvx decrypt

Pros: Version controlled, simple Cons: Key management complexity, all-or-nothing access

Option 3: Password manager (1Password, Bitwarden)

Store secrets in a shared vault:

# 1Password CLI
op read "op://Vault/Project/.env" > .env.local

Pros: Familiar UX, access control Cons: Manual sync, no CI/CD integration

Option 4: Secrets managers (Doppler, Infisical, Keyway, etc.)

Dedicated tools for secrets management:

# Doppler
doppler secrets download --no-file --format env > .env.local

# Infisical
infisical export > .env.local

# Keyway
keyway pull

Pros: Access control, audit logs, CI/CD integration, sync to platforms Cons: Another tool to manage, potential vendor lock-in

Option 5: Platform-native (Vercel, Netlify, etc.)

Store secrets directly in your hosting platform:

# Vercel CLI
vercel env pull .env.local

Pros: No extra tools, integrated with deployments Cons: Tied to one platform, limited local dev workflow


The Deployment Sync Problem

One pain point that catches teams off guard: keeping environment variables in sync between your local setup and your hosting platform.

The typical failure scenario:

  1. You add STRIPE_WEBHOOK_SECRET to .env.local
  2. You update your code to use it
  3. You push to main
  4. Build passes (using old cached env vars)
  5. Production breaks because the variable doesn't exist on Vercel

Why this happens:

  • Hosting platforms don't know about your local .env changes
  • There's no automatic sync between local and deployed environments
  • Each platform has a different UI/CLI for managing variables
  • No one remembers to update the dashboard after local changes

Solutions:

  1. CLI-first workflow: Always update via CLI, not dashboard

    # Vercel
    vercel env add STRIPE_WEBHOOK_SECRET production
    
    # Netlify
    netlify env:set STRIPE_WEBHOOK_SECRET "whsec_..."
    
  2. Pre-deploy checklist: Add env var verification to your PR template

  3. Validation at build time: Use @t3-oss/env-nextjs to fail the build if variables are missing

  4. Sync tools: Doppler, Infisical, and Keyway can push to multiple platforms from a single source of truth

The key insight: treat environment variables as part of your deployment, not an afterthought.


CI/CD Patterns

Your CI/CD pipeline needs access to secrets without committing them.

GitHub Actions with repository secrets

# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

env:
  DATABASE_URL: ${{ secrets.DATABASE_URL }}
  STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build
        run: npm run build
        env:
          NEXT_PUBLIC_APP_URL: https://myapp.com

Using OIDC for cloud providers

Instead of storing cloud credentials as secrets, use OIDC:

permissions:
  id-token: write
  contents: read

steps:
  - uses: aws-actions/configure-aws-credentials@v4
    with:
      role-to-assume: arn:aws:iam::123456789:role/github-actions
      aws-region: us-east-1

Generating .env file in CI

- name: Create .env file
  run: |
    cat << EOF > .env.local
    DATABASE_URL=${{ secrets.DATABASE_URL }}
    STRIPE_SECRET_KEY=${{ secrets.STRIPE_SECRET_KEY }}
    EOF

- name: Build
  run: npm run build

Common Mistakes and How to Avoid Them

1. Putting secrets in NEXT_PUBLIC_ variables

# WRONG - visible in browser
NEXT_PUBLIC_DATABASE_URL=postgres://user:pass@host/db

# RIGHT - server only
DATABASE_URL=postgres://user:pass@host/db

2. Dynamic access to env vars

// WRONG - doesn't work for NEXT_PUBLIC_ vars
const key = 'NEXT_PUBLIC_API_URL'
const value = process.env[key] // undefined

// RIGHT - direct access only
const value = process.env.NEXT_PUBLIC_API_URL

3. Forgetting to restart after changes

Environment variables are loaded when the dev server starts. After editing .env.local:

# Kill the server (Ctrl+C) and restart
npm run dev

4. Expecting .env.local in production builds

.env.local is for local development. In production:

  • Use your hosting platform's env vars UI
  • Or set them in your CI/CD pipeline
  • Or use a secrets manager

5. Not documenting required variables

Always maintain a .env.example:

# .env.example (committed to git)
# Copy to .env.local and fill in values

# Required
DATABASE_URL=
STRIPE_SECRET_KEY=

# Optional (defaults shown)
LOG_LEVEL=info

Security Checklist

Before deploying:

  • No secrets in NEXT_PUBLIC_ variables
  • .env.local and .env*.local in .gitignore
  • Secrets not logged or exposed in error messages
  • Different secrets for dev/staging/production
  • Secrets rotated when team members leave
  • Validation fails build if variables missing
  • .env.example documents all required variables

Quick Reference

// Server Component - has access to all env vars
export default function Page() {
  const dbUrl = process.env.DATABASE_URL // ✅ works
  const apiUrl = process.env.NEXT_PUBLIC_API_URL // ✅ works
}

// Client Component - only NEXT_PUBLIC_
'use client'
export default function Button() {
  const dbUrl = process.env.DATABASE_URL // ❌ undefined
  const apiUrl = process.env.NEXT_PUBLIC_API_URL // ✅ works
}

// API Route - has access to all env vars
export async function GET() {
  const dbUrl = process.env.DATABASE_URL // ✅ works
}

Further Reading