Next.js Environment Variable Hygiene: Keeping Secrets Out of Client Bundles
Part of Building This Site and Small Business Websites
By Paul Peery · September 13, 2026 · 5 min read

The moment you slap NEXT_PUBLIC_ onto an environment variable, you are not configuring a secure server setting; you are giving the compiler permission to paste that raw string directly into public browser files. In the Next.js App Router, the boundary between backend logic and browser rendering feels delightfully small, but that exact convenience makes it dangerously easy to send sensitive credentials over the wire.
Keeping private API tokens, database connection strings, and service role keys off the public web does not require complex tooling. It requires understanding how Next.js handles compile-time replacement, how component props serialize, and where to set up automatic guardrails before deploying.
NEXT_PUBLIC_ is a find-and-replace compiler macro
Many developers assume process.env in Next.js works like a global configuration object that runs permission checks at runtime. It does not.
When you build a Next.js project with Turbopack or Webpack, the bundler inspects your client-side code for literal matches of process.env.NEXT_PUBLIC_*. When it finds one, it performs an AST string replacement. If you have NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_123", the compiler literally rewrites your source code from this:
const stripeKey = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY;
into this:
const stripeKey = "pk_test_123";
Because this is simple static replacement, two dangerous things happen. First, if you accidentally rename a database secret to NEXT_PUBLIC_DATABASE_URL so a client hook can grab it quickly, that entire database connection string—user, password, host, and port—gets hardcoded into a public .js file that anyone can read in browser DevTools. Second, dynamic lookups like process.env[variableName] fail on the client because the compiler can only substitute explicit, static member expressions.
Server Component props leak through the wire format
You can keep the NEXT_PUBLIC_ prefix off your private keys and still leak them to the browser without realizing it. This happens at the component boundary.
In the App Router, Server Components run strictly on the server and have direct access to private variables like process.env.RESEND_API_KEY. But when a Server Component renders a Client Component marked with 'use client', React serializes every prop passed across that boundary into a JSON-like stream (the React Server Component payload).
If you pass an entire configuration object or user record into a client component:
// app/dashboard/page.tsx (Server Component)
import UserSettings from './UserSettings';
export default async function Page() {
const config = {
siteUrl: 'https://example.com',
adminToken: process.env.INTERNAL_ADMIN_TOKEN,
};
return <UserSettings config={config} />;
}
That adminToken travels across the network in plain text inside the initial page response. Even if UserSettings.tsx never renders the token on screen, anyone opening the browser's Network tab can view the raw payload and copy it. When splitting UI between environments, as I outline in my Server Components vs. Client Components rulebook, pass only primitive, display-ready values across the boundary—never entire database rows or raw configuration bags.
Lock down sensitive files with the server-only package
The most reliable way to stop server code from slipping into client bundles is to make the build fail immediately if someone imports it where it doesn't belong.
Next.js provides an official package for this called server-only. You install it once:
npm install server-only
Then add a single import to the very top of any utility module that handles database calls, payment processing, or secret keys:
// lib/database.ts
import 'server-only';
export async function getAdminData() {
const secret = process.env.DATABASE_SECRET_KEY;
// ...
}
If you or an external dependency accidentally import lib/database.ts into a Client Component (or any file imported by a Client Component), Next.js throws an error during compilation and refuses to build. Whether you are querying a Supabase or Postgres database or running internal operations, this single line turns an invisible data leak into a loud build break.
Here is the honest trade-off: server-only protects module imports, but it cannot inspect what you return from Server Actions or Server Component props. If your server function runs safely on the backend but returns { secretKey: process.env.MY_KEY } to a client caller, server-only will not stop it. Module boundaries prevent bundling leaks; your return values still require disciplined manual code review.
Never use the legacy next.config.js env object
If you look at older Next.js tutorials, you will often see environment variables declared inside next.config.js like this:
// next.config.js - AVOID THIS PATTERN
module.exports = {
env: {
API_SECRET: process.env.API_SECRET,
},
};
Avoid this configuration completely. Any key placed inside the env object in next.config.js is mapped directly to the build-time replacement pipeline. That means Next.js treats it as a public variable and inlines it into client bundles, even if its name lacks the NEXT_PUBLIC_ prefix.
Instead, rely solely on the standard .env file hierarchy. For local development, put secrets in .env.local. When handling backend workflows like spam-resistant forms using Server Actions, keep the keys un-prefixed in .env.local and call them strictly within server functions.
Keep .env*.local files listed in your .gitignore from day one. Commit a sanitized .env.example file to version control containing only the variable names with empty or placeholder values so you know what the project expects without publishing live secrets.
Validate your environment schema at build time
Silent undefined values cause nearly as many production bugs as leaked credentials. You can solve both problems simultaneously by defining a strict schema using @t3-oss/env-nextjs alongside Zod.
This utility forces you to explicitly declare which variables belong on the server and which belong on the client:
// env.ts
import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod';
export const env = createEnv({
server: {
RESEND_API_KEY: z.string().min(1),
DATABASE_URL: z.string().url(),
},
client: {
NEXT_PUBLIC_SITE_URL: z.string().url(),
},
runtimeEnv: {
RESEND_API_KEY: process.env.RESEND_API_KEY,
DATABASE_URL: process.env.DATABASE_URL,
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
},
});
This setup provides two immediate safeguards:
- If you forget to set a required variable in production, the build crashes before any users hit broken pages.
- If a client-side component tries to import
env.RESEND_API_KEY, the library throws a runtime and build-time error telling you that a server-side variable was accessed on the client.
The one-minute deployment sanity check
Before running your next production build or pushing code, run through these four practical checks:
- Audit your prefix count: Run a quick search across your codebase for
NEXT_PUBLIC_. If a variable represents an API secret, a private signing key, or a database URL, remove the prefix immediately. - Verify git tracking: Run
git status --ignoredto confirm your.env.localfile is grayed out and safely ignored. - Isolate backend files: Add
import 'server-only'to every file in yourlib/orservices/directory that handles authentication, billing, or direct database queries. - Inspect the client bundle: After running
next build, open the output files or inspect the network tab on your production preview. Check the JavaScript chunks and page props to confirm no unintended keys appear in the raw text.
Keep reading
All posts
Server Components vs. Client Components: A Practical Solo Dev Rulebook
Most Next.js tutorials make Server Components look like an all-or-nothing rewrite. Here is the exact mental model I use to decide where 'use client' belongs.
August 15, 2026 · 4 min read