Skip to content
C Codeloom
Next.js

Next.js Environment Variables and Secrets

Learn how Next.js loads environment variables, when they are exposed to the browser, and how to keep secrets out of your client bundle.

·4 min read · By Codeloom
Beginner 8 min read

What you'll learn

  • How Next.js loads .env files
  • The role of the NEXT_PUBLIC prefix
  • When variables are inlined at build time
  • Per-environment files like .env.local
  • Auditing your bundle for leaked secrets

Prerequisites

  • Comfortable with HTML and JavaScript

What and Why

Environment variables hold configuration that changes between machines: API keys, database URLs, feature flags. Next.js has a specific algorithm for finding them and a special prefix that controls whether they are sent to the browser. Misunderstanding that algorithm is how secrets end up in public JavaScript bundles.

Mental Model

Next.js reads environment files at build time and at server start. Anything without a prefix is available only inside server code. Anything starting with NEXT_PUBLIC_ is inlined into the JavaScript bundles the browser downloads. There is no runtime fetch involved for public values, which means they cannot be rotated without a rebuild.

.env files
 |
 v
build time
 |--- NEXT_PUBLIC_X  -> inlined into client bundle
 |--- DATABASE_URL   -> server only, read at runtime
 v
deployed app
Where each variable ends up

Hands-on Example

A typical setup uses several files.

.env                  # checked in defaults
.env.local            # ignored by git, real secrets
.env.development      # used by next dev
.env.production       # used by next start
# .env
NEXT_PUBLIC_SITE_URL=https://example.com
NEXT_PUBLIC_ANALYTICS_ID=ga-default
# .env.local
DATABASE_URL=postgres://user:pass@localhost:5432/app
STRIPE_SECRET_KEY=sk_live_abc123

Server code can read anything.

// app/api/checkout/route.ts
export async function POST(request: Request) {
  const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
  const session = await stripe.checkout.sessions.create({/* ... */});
  return Response.json({ id: session.id });
}

Client code can only read public variables.

'use client';

export function AnalyticsBeacon() {
  return <script src={`/api/track?id=${process.env.NEXT_PUBLIC_ANALYTICS_ID}`} />;
}

If you accidentally use process.env.STRIPE_SECRET_KEY in a client component, Next.js replaces it with undefined at build time. The compiler never inlines secrets, but it also gives you no runtime error, so the code silently breaks.

Common Pitfalls

The most painful pitfall is reading a server-only variable from shared code that ends up imported by both server and client components. Once tree shaking pulls in a single line that touches process.env.DATABASE_URL from a client bundle, you do not get the value, and the bug is hard to spot. Move secrets behind a server-only module and import it from server components only. The server-only package throws if it is ever imported from the client, which is a great safety net.

// lib/db.ts
import 'server-only';
import { Pool } from 'pg';

export const pool = new Pool({ connectionString: process.env.DATABASE_URL });

Another mistake is assuming NEXT_PUBLIC_ values update without a redeploy. Because they are inlined, changing the variable in your hosting dashboard does nothing until you rebuild. Use a runtime fetch to a config endpoint if you need true runtime toggles.

People also forget that .env.local is loaded in every environment, including production builds run locally. Keep secrets that should never leak in your hosting provider rather than in the file.

Practical Tips

Audit your client bundle. Run next build and search the .next/static directory for known prefixes of your secret values. If you see them, you have a leak.

Validate environment variables on boot. Libraries like @t3-oss/env-nextjs or a small Zod schema can throw on missing or malformed values, which is far better than a 500 deep in production.

import { z } from 'zod';

export const env = z
  .object({
    DATABASE_URL: z.string().url(),
    NEXT_PUBLIC_SITE_URL: z.string().url(),
  })
  .parse(process.env);

Use a different secret per environment. Sharing a Stripe key between staging and production is the most common way to charge real cards by mistake.

For local development, prefer a personal .env.local per developer. Document the required keys in a .env.example file that is safe to check in.

Wrap-up

Environment variables in Next.js are simple if you remember one rule: the NEXT_PUBLIC_ prefix opts a value into the browser bundle, everything else is server only. Combine that with server-only for sensitive modules, validate at boot, and audit your build output, and you can sleep easy that no secrets are leaking to your users.