Dev Encyclopedia
ArticlesToolsContactAbout

Get notified when new content drops

No spam. Just new articles, tools, and updates straight to your inbox.

Dev Encyclopedia

A reference for builders

Content

  • Articles
  • Tools
  • About
  • Contact

Connect

  • support@devencyclopedia.com
  • RSS Feed

Legal

  • Privacy Policy
  • Terms of Service
  • Disclaimer

© 2026 Dev Encyclopedia

Back to top ↑
  1. Home
  2. /Blog
  3. /How to Use Environment Variables in Next.js (Without Leaking Them to the Browser)
nextjs12 min read

How to Use Environment Variables in Next.js (Without Leaking Them to the Browser)

Learn how to use .env files in Next.js correctly. Understand NEXT_PUBLIC_, avoid common mistakes, and set variables in Vercel and Cloudflare.

Zeeshan Tofiq
Zeeshan Tofiq
May 30, 2026·Updated June 24, 2026
On this page

On this page

  • How Next.js Environment Variables Work
  • The NEXT_PUBLIC_ Prefix
  • Build-Time vs Runtime Variables
  • Type-Safe Environment Variables
  • Step-by-Step Setup
  • Create .env.local
  • Commit .env.example for your team
  • Set variables in Vercel and Cloudflare
  • Add validation for missing variables
  • Environment Variables in Docker
  • Environment Variables in Tests
  • Common Mistakes and How to Fix Them
  • Destructuring process.env
  • Quoting Values Incorrectly
  • Forgetting to Restart the Dev Server
  • Pre-Deploy Security Checklist
  • Frequently Asked Questions

Every Next.js developer hits this wall at some point. You add a .env.local file, reference process.env.MY_API_KEY in your code, and everything works fine on your machine. Then you deploy. Either the variable is missing in production, or worse, it shows up in your browser's network tab for anyone to see.

This guide explains how environment variables actually work in Next.js, what the NEXT_PUBLIC_ prefix does, how to validate them at build time, and how to configure things correctly for Vercel, Cloudflare Workers, Docker, and any CI/CD pipeline.

How Next.js Environment Variables Work

Environment variables are key-value pairs your app reads at runtime or build time. They let you store secrets outside source code, run the same codebase differently per environment, and prevent credentials from being hardcoded into files that get committed.

Next.js reads four .env files in a specific order. When the same variable exists in multiple files, .env.local always wins:

bash
.env               # loaded in all environments
.env.local         # loaded in all environments, git-ignored (secrets live here)
.env.development   # loaded only during next dev
.env.production    # loaded only during next build and next start

The loading order matters because later files override earlier ones. If you define API_URL=http://localhost:3000 in .env and API_URL=https://api.prod.com in .env.production, the production value is used during next build and next start, while the localhost value applies during next dev.

You can also use .env.development.local and .env.production.local for machine-specific overrides in each environment. These local variants are always git-ignored and take the highest priority within their environment.

Next.js .env file loading order and priority
FileLoaded whenCommitted to Git?Priority
.envAll environmentsYesLowest
.env.developmentnext dev onlyYesMedium
.env.productionnext build / next startYesMedium
.env.localAll environmentsNo (git-ignored)High
.env.development.localnext dev onlyNo (git-ignored)Highest (in dev)
.env.production.localnext build / next startNo (git-ignored)Highest (in prod)

⚠ Warning

If .env.local is not in your .gitignore, add it right now. Committing secrets to a repository, even a private one, is a common source of credential leaks.

The NEXT_PUBLIC_ Prefix: Handle with Care

Next.js runs in two contexts, and environment variables behave differently in each. By default all variables are server-only. The NEXT_PUBLIC_ prefix explicitly opts a variable into the browser bundle.

At build time, Next.js scans your code for any reference to process.env.NEXT_PUBLIC_* and replaces it with the literal string value. This means the value is baked into the JavaScript output. There is no runtime lookup, no hidden API call. The raw value sits in your JS bundle for anyone to read.

Server-only (safe for secrets)NEXT_PUBLIC_ (visible in browser)
Available inServer Components, API routes, server actionsClient Components, browser, anywhere
In DevTools?NeverYes (fully visible)
Use forDATABASE_URL, API_SECRET_KEY, tokensAnalytics keys, public API URLs, site URLs
Strips from bundle?Yes: stripped at build timeNo: inlined into JS bundle
Can change without rebuild?Yes (runtime env on server)No (baked in at build time)
bash
# Safe to expose: use NEXT_PUBLIC_
NEXT_PUBLIC_POSTHOG_KEY=phc_xxx
NEXT_PUBLIC_API_URL=https://api.example.com

# Must stay server-only, no NEXT_PUBLIC_ prefix
DATABASE_URL=postgresql://localhost:5432/mydb
API_SECRET_KEY=your-secret-here

🚫 Danger

NEXT_PUBLIC_ variables are visible to anyone who opens DevTools. Never use that prefix for API secrets, database credentials, or any value you would not put on a public webpage.

Build-Time vs Runtime Variables

One of the most confusing aspects of Next.js environment variables is the difference between build-time and runtime resolution. Understanding this distinction prevents a whole class of deployment bugs.

NEXT_PUBLIC_ variables are always resolved at build time. Next.js literally replaces process.env.NEXT_PUBLIC_API_URL with the string "https://api.example.com" in the compiled output. If you change the variable in your hosting dashboard and do not rebuild, the old value persists in the bundle.

Server-only variables (without the NEXT_PUBLIC_ prefix) behave differently depending on your rendering strategy. In static pages generated at build time with generateStaticParams, the values are captured during next build. In API routes or server actions that run on every request, the variables are read from the runtime environment.

typescript — Build-time vs runtime example
// This value is baked into the HTML at build time
// Changing the env var later has no effect without a rebuild
export default function HomePage() {
  return <p>Version: {process.env.NEXT_PUBLIC_APP_VERSION}</p>;
}

// This value is read fresh on every request
// Changing the env var takes effect immediately
export async function GET() {
  const secret = process.env.API_SECRET_KEY;
  return Response.json({ status: "ok" });
}

ℹ Info

If you need a public variable that can change without rebuilding, fetch it from a server API route instead of using NEXT_PUBLIC_. The API route reads the runtime environment and returns the value to the client.

Type-Safe Environment Variables with Validation

process.env values are always string | undefined in TypeScript. That means every access requires a null check or a type assertion, and you will not discover a missing variable until the code actually runs. For production apps, validating environment variables at build time prevents runtime crashes.

The most reliable approach is a small validation module that runs at startup. If any required variable is missing, the build fails immediately with a clear error message instead of silently producing undefined values.

typescript — src/lib/env.ts
// Validate and export typed environment variables
// Import this module wherever you need env vars

function requireEnv(key: string): string {
  const value = process.env[key];
  if (!value) {
    throw new Error(
      `Missing required environment variable: ${key}. ` +
      `Check your .env.local file or hosting dashboard.`
    );
  }
  return value;
}

// Server-only variables (never import this file in client components)
export const env = {
  DATABASE_URL: requireEnv("DATABASE_URL"),
  API_SECRET_KEY: requireEnv("API_SECRET_KEY"),
  TURSO_AUTH_TOKEN: requireEnv("TURSO_AUTH_TOKEN"),
} as const;

// Public variables (safe to use anywhere)
export const publicEnv = {
  SITE_URL: requireEnv("NEXT_PUBLIC_SITE_URL"),
  POSTHOG_KEY: requireEnv("NEXT_PUBLIC_POSTHOG_KEY"),
} as const;

With this pattern, you get autocompletion from your IDE, type safety without assertions, and an immediate build failure if someone forgets to set a variable. Every team member sees a clear error message pointing to the exact missing key.

💡 Tip

For more robust validation, consider using a library like zod or @t3-oss/env-nextjs. These tools let you define schemas with types, defaults, and custom error messages for each variable.

typescript — Zod-based validation example
import { z } from "zod";

const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  API_SECRET_KEY: z.string().min(16),
  NODE_ENV: z.enum(["development", "production", "test"]),
});

// Throws a detailed error if validation fails
export const env = envSchema.parse(process.env);

Step-by-Step Setup

  1. 1

    Create .env.local and add your variables

    Create .env.local at the project root (same level as package.json). How you access a variable depends on the prefix:

    bash — .env.local
    # Server-only (no prefix)
    DATABASE_URL=postgresql://localhost:5432/mydb
    API_SECRET_KEY=your-secret-here
    
    # Available everywhere (NEXT_PUBLIC_)
    NEXT_PUBLIC_SITE_URL=http://localhost:3000
    NEXT_PUBLIC_POSTHOG_KEY=phc_xxx
    typescript — Accessing variables in your code
    // In a Server Component or API route (safe: server only)
    const dbUrl = process.env.DATABASE_URL;
    
    // In a Client Component (only works with NEXT_PUBLIC_)
    const siteUrl = process.env.NEXT_PUBLIC_SITE_URL;

    💡 Tip

    After adding a new variable to .env.local, restart the dev server. Next.js reads .env files once at startup, not on every request.

  2. 2

    Commit a .env.example for your team

    .env.local is git-ignored, so anyone cloning your repo won't know which variables are required. Commit a .env.example with blank values:

    bash — .env.example (commit this to Git)
    DATABASE_URL=
    API_SECRET_KEY=
    TURSO_DATABASE_URL=
    TURSO_AUTH_TOKEN=
    NEXT_PUBLIC_SITE_URL=http://localhost:3000
    NEXT_PUBLIC_POSTHOG_KEY=

    💡 Tip

    New team members copy .env.example to .env.local and fill in the real values. Keep .env.example updated whenever you add a new required variable.

  3. 3

    Set variables in Vercel and Cloudflare

    Neither platform reads .env files from your repository. Variables must be added through the dashboard:

    • Vercel: Project > Settings > Environment Variables > add each variable > select environments (Production, Preview, Development) > save > redeploy
    • Cloudflare: Project > Settings > Environment Variables > add variables under Production or Preview > Save and Deploy
    • Cloudflare Workers runs at the edge, not in Node.js: test your variable access after the first deploy to confirm everything resolves correctly.

    ℹ Info

    Changing environment variables in Vercel or Cloudflare does not automatically redeploy your app. Trigger a new deployment by pushing a commit or clicking the redeploy button in the dashboard.

  4. 4

    Add validation to catch missing variables early

    Create a validation module (like the src/lib/env.ts example above) and import it in your app's entry points. If any required variable is missing, the build will fail with a descriptive error rather than producing subtle runtime bugs.

    Import the validation module in your root layout or a top-level server component so it runs during the build step:

    typescript — src/app/layout.tsx
    import "@/lib/env"; // Validates all env vars at build time
    
    export default function RootLayout({
      children,
    }: {
      children: React.ReactNode;
    }) {
      return (
        <html lang="en">
          <body>{children}</body>
        </html>
      );
    }

Environment Variables in Docker

Docker adds another layer to the equation. Variables can be injected at build time (when the image is created) or at runtime (when the container starts). Mixing these up is a frequent source of deployment bugs.

For NEXT_PUBLIC_ variables, the values must be available during docker build because Next.js inlines them at compile time. Pass them as build arguments. For server-only variables, inject them at runtime using -e flags or an env file so you can change them without rebuilding the image.

dockerfile — Dockerfile
FROM node:20-alpine AS builder

# Build-time variables (NEXT_PUBLIC_ values get baked into the bundle)
ARG NEXT_PUBLIC_SITE_URL
ARG NEXT_PUBLIC_POSTHOG_KEY
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
ENV NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY

WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/package*.json ./

# Server-only vars are injected at runtime, not build time
# docker run -e DATABASE_URL=... -e API_SECRET_KEY=... my-app
CMD ["npm", "start"]
bash — Build and run commands
# Pass NEXT_PUBLIC_ values at build time
docker build \
  --build-arg NEXT_PUBLIC_SITE_URL=https://example.com \
  --build-arg NEXT_PUBLIC_POSTHOG_KEY=phc_xxx \
  -t my-next-app .

# Pass server-only secrets at runtime
docker run \
  -e DATABASE_URL=postgresql://db:5432/prod \
  -e API_SECRET_KEY=real-secret-here \
  -p 3000:3000 \
  my-next-app

⚠ Warning

Never put secrets in ARG or in a Dockerfile ENV instruction. Build arguments are stored in the image layer history and can be extracted by anyone with access to the image. Use runtime -e flags or Docker secrets for sensitive values.

Environment Variables in Tests

Tests need their own set of environment variables. You do not want your test suite hitting a production database or a live API. Next.js does not load .env files automatically during test runs, so you need to handle this explicitly.

If you are using Jest or Vitest, create a .env.test file and load it in your test setup. Alternatively, define test values directly in your test configuration.

typescript — vitest.config.ts
import { defineConfig } from "vitest/config";
import { loadEnv } from "vite";

export default defineConfig({
  test: {
    env: {
      DATABASE_URL: "postgresql://localhost:5432/test_db",
      API_SECRET_KEY: "test-secret-key-not-real",
      NEXT_PUBLIC_SITE_URL: "http://localhost:3000",
    },
  },
});
bash — .env.test
# Loaded manually in test setup (not auto-loaded by Next.js)
DATABASE_URL=postgresql://localhost:5432/test_db
API_SECRET_KEY=test-secret-key-not-real
NEXT_PUBLIC_SITE_URL=http://localhost:3000

💡 Tip

Next.js has special handling for .env.test: it is loaded automatically when NODE_ENV is set to test. This works out of the box if your test runner sets NODE_ENV=test before starting (most do by default).

Common Mistakes and How to Fix Them

After years of Next.js projects, certain environment variable mistakes come up repeatedly. Here are the most common pitfalls and their fixes.

Destructuring process.env

Next.js replaces process.env.VARIABLE_NAME with the actual value using a simple string substitution at build time. If you destructure process.env, the replacement cannot find the full expression and the variable resolves to undefined.

typescript — Destructuring breaks variable resolution
// This will NOT work: destructuring breaks the string replacement
const { NEXT_PUBLIC_API_URL } = process.env;
console.log(NEXT_PUBLIC_API_URL); // undefined

// This works: direct property access
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
console.log(apiUrl); // "https://api.example.com"

// This also fails: dynamic key access
const key = "NEXT_PUBLIC_API_URL";
console.log(process.env[key]); // undefined in client components

⚠ Warning

Always use process.env.EXACT_VARIABLE_NAME with the full, literal string. Destructuring, computed property access, and helper functions that dynamically construct the key will all produce undefined in client components.

Quoting Values Incorrectly

Next.js .env files follow the dotenv convention. Quotes are optional for simple values, but they matter for values containing spaces or special characters. A common mistake is adding quotes around a URL, which includes the literal quote characters in the value.

bash — Quoting rules in .env files
# Correct: no quotes needed for simple values
DATABASE_URL=postgresql://localhost:5432/mydb

# Correct: use double quotes for values with spaces
APP_NAME="My Next App"

# Wrong: the quotes become part of the value
DATABASE_URL="postgresql://localhost:5432/mydb"
# process.env.DATABASE_URL === '"postgresql://localhost:5432/mydb"'
# This breaks database connections!

# Correct: single quotes prevent variable expansion
GREETING='Hello $USER'
# process.env.GREETING === "Hello $USER" (literal dollar sign)

Forgetting to Restart the Dev Server

Unlike hot module replacement for code changes, .env files are only read when the dev server starts. If you add or change a variable and do not restart next dev, the old value (or undefined) persists. This catches developers off guard because code changes update instantly, but environment changes do not.

Pre-Deploy Security Checklist

Run through this before every deployment. These are also the most common sources of environment variable bugs:

  • .env.local is listed in .gitignore
  • No secret keys use the NEXT_PUBLIC_ prefix
  • .env.example is committed to Git with blank values
  • Production variables are set in the platform dashboard, not committed to any file
  • Every API key has the minimum permissions it needs
  • Added new variables to CI/CD secrets (GitHub Actions cannot read .env.local)
  • Triggered a new deployment after changing dashboard variables
  • Environment validation module runs during build and fails fast on missing vars
  • Docker images do not contain secrets in build layers (docker history shows no sensitive values)

💡 Tip

If a variable works locally but is undefined in production, check three things: Is it set in the platform dashboard? Did you redeploy after setting it? Is it a server-only variable being accessed in a client component?

Frequently Asked Questions

Why is process.env.MY_VAR undefined in my client component?

Environment variables are server-side only by default. Next.js strips them from the client bundle at build time to prevent accidental secret leakage.

bash
# Won't work in a client component
MY_API_KEY=secret123

# Accessible in the browser
NEXT_PUBLIC_SITE_URL=https://example.com

Rename the variable with the NEXT_PUBLIC_ prefix if the value is safe to expose publicly. Then restart the dev server and rebuild. If the value is a secret, do not add the prefix. Instead, create a server API route that reads the secret and returns only the data the client needs.

What is the difference between .env and .env.local?
.env.env.local
Committed to Git?YesNo (git-ignored)
Shared with team?YesNo (machine-specific)
Use forNon-secret shared defaultsAll secrets and personal values
Override priorityLowerHigher (always wins)

Use .env for non-sensitive defaults that the whole team shares (like NEXT_PUBLIC_SITE_URL=http://localhost:3000). Put all secrets and machine-specific values in .env.local, which Git ignores by default.

Do I need to redeploy after changing environment variables in Vercel or Cloudflare?

Yes. Both platforms inject variables at build time. A change in the dashboard takes effect only on the next deployment: push a commit or click the manual redeploy button to apply it.

For NEXT_PUBLIC_ variables this is especially important because the values are inlined into the JavaScript bundle. Even server-only variables on Vercel require a redeploy because Vercel creates a new serverless function bundle on each build.

Can people see my NEXT_PUBLIC_ variables in the browser?

Yes. NEXT_PUBLIC_ variables are inlined into the JavaScript bundle at build time and are fully visible in DevTools. Anyone can open the Sources or Network tab and search for the value. Only use this prefix for values you are comfortable making completely public, such as analytics IDs, public API base URLs, or feature flags.

How do I use environment variables in GitHub Actions?

Add secrets in the repository under Settings > Secrets and Variables > Actions. Reference them in your workflow file:

yaml — .github/workflows/deploy.yml
jobs:
  build:
    steps:
      - name: Build
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
          API_SECRET_KEY: ${{ secrets.API_SECRET_KEY }}
          NEXT_PUBLIC_SITE_URL: ${{ vars.NEXT_PUBLIC_SITE_URL }}
        run: npm run build

.env.local is never available in CI: every required variable must be added explicitly to repository secrets. For non-secret public values, use GitHub's vars context instead of secrets so the values are visible in workflow logs for easier debugging.

Why does destructuring process.env break my variables?

Next.js uses a compile-time string replacement, not a real runtime object lookup. It scans your code for the literal text process.env.VARIABLE_NAME and swaps it with the value. When you destructure (const { MY_VAR } = process.env), the compiler cannot match the pattern and the replacement does not happen.

typescript
// Broken: destructuring
const { NEXT_PUBLIC_API_URL } = process.env;

// Working: direct access
const apiUrl = process.env.NEXT_PUBLIC_API_URL;

This limitation applies to NEXT_PUBLIC_ variables in client components. On the server side, process.env is a real object and destructuring works normally. However, using direct access everywhere is a safer habit.

How do I use different values for development, staging, and production?

For development vs production, use .env.development and .env.production. Next.js loads the correct file based on the command (next dev vs next build). For staging, the approach depends on your hosting platform.

bash — Per-environment files
# .env.development (local dev)
NEXT_PUBLIC_API_URL=http://localhost:4000/api

# .env.production (production build)
NEXT_PUBLIC_API_URL=https://api.example.com

For staging environments on Vercel, set variables scoped to the Preview environment in the dashboard. On Cloudflare, use the Preview environment settings. This keeps staging values separate from production without adding more .env files to your repository.

Do environment variables work in Next.js Edge Runtime and Middleware?

process.env is available in Edge Runtime and Middleware, but with limitations. The Edge Runtime does not have access to the full Node.js process object. Instead, process.env is a statically analyzed object that Next.js populates at build time.

This means NEXT_PUBLIC_ variables work as expected. Server-only variables also work, but only if they are referenced with the literal process.env.VARIABLE_NAME syntax. Dynamic access patterns (like process.env[dynamicKey]) will not resolve. On platforms like Cloudflare Workers, make sure the variables are configured in the platform's environment settings, not just in local .env files.

Environment variables in Next.js are simple once the mental model clicks: server-side by default, client-side only when you opt in with NEXT_PUBLIC_, and never committed to Git when they contain secrets.

Keep a .env.example file up to date as your project grows. Add a validation module that fails the build on missing variables. Set production values in the platform dashboard, not in committed files. Pass NEXT_PUBLIC_ values as Docker build arguments and server secrets as runtime environment variables.

Run through the security checklist before every deployment. A few minutes of verification prevents the kind of credential leak that takes days to remediate.

Zeeshan Tofiq

Zeeshan Tofiq

Full Stack Developer

Full stack developer with over 6 years of experience building production applications. Writes practical guides on JavaScript, TypeScript, React, Node.js, and cloud infrastructure. Focused on helping developers solve real problems with clean, maintainable code.

Enjoyed this article?

Get practical dev guides, tool updates, and new articles delivered straight to your inbox. No spam, unsubscribe anytime.

Related Articles

nextjs

Husky + Prettier + lint-staged Setup for Next.js

Set up Husky v9, Prettier, and lint-staged in your Next.js project. Step-by-step guide covering pre-commit hooks with the correct 2026 config.

May 30, 2026·8 min read
databases

Drizzle ORM Migrations: A Practical drizzle-kit Guide

Learn the full Drizzle ORM migration workflow: push vs migrate, drizzle-kit setup, Turso/libSQL config, team conflicts, and production best practices.

May 30, 2026·9 min read

On this page

  • How Next.js Environment Variables Work
  • The NEXT_PUBLIC_ Prefix
  • Build-Time vs Runtime Variables
  • Type-Safe Environment Variables
  • Step-by-Step Setup
  • Create .env.local
  • Commit .env.example for your team
  • Set variables in Vercel and Cloudflare
  • Add validation for missing variables
  • Environment Variables in Docker
  • Environment Variables in Tests
  • Common Mistakes and How to Fix Them
  • Destructuring process.env
  • Quoting Values Incorrectly
  • Forgetting to Restart the Dev Server
  • Pre-Deploy Security Checklist
  • Frequently Asked Questions
Advertisement