Vercel Builds a Next.js App with `process.env` Undefined in Client Code
process.env.API_KEY is undefined in the browser bundle shipped by Vercel, and the client code fails with TypeError: Cannot read properties of undefined.
Why this happens
Next.js runs code in two different places.
Server code executes in Node.js during rendering, route handlers, server actions, and build steps. That environment can read real process environment variables from the deployment platform or local shell.
Client code runs in the browser. The browser has no process.env object from Node.js. In modern Next.js builds, the client bundle replaces process.env references only when they are statically known and allowed for client exposure. Anything else is removed, inlined, or left inaccessible depending on where the code lives.
That separation is the core issue:
- server-only variables exist only at runtime on the server
- build-time substitutions happen when Next.js compiles code
- client-safe variables must be explicitly exposed with the
NEXT_PUBLIC_prefix
If client code tries to read a server-only variable such as process.env.API_KEY, the browser bundle cannot reliably access it. The value is either omitted or replaced with undefined, and any property access or usage can fail.
The three environment variable paths in Next.js
Next.js has three distinct categories of values.
1. Build-time embedded values
Some values are read when Next.js builds the app. The compiler can inline them into generated JavaScript if they are statically referenced and permitted for the target bundle.
Example:
tsconst mode = process.env.NODE_ENV;
NODE_ENV is a common build-time and runtime value. In client bundles, Next.js may replace it during compilation.
Build-time embedding matters because once code ships to the browser, it cannot go back to the server to ask for a secret value.
2. Runtime server variables
These are values available only in the server process at request time or build time on the server side.
Examples include:
DATABASE_URLSTRIPE_SECRET_KEYAPI_KEYREDIS_URL
These should stay server-only. If client code can access them, they are no longer secrets.
3. NEXT_PUBLIC_ values
Any environment variable prefixed with NEXT_PUBLIC_ is intentionally exposed to browser bundles.
Example:
envNEXT_PUBLIC_API_BASE_URL=https://api.example.com
This is safe only for non-secret values. It is not encrypted, not hidden, and not server-only. It becomes part of the shipped JavaScript or the page payload depending on how it is used.
What the browser actually receives
A client component or browser-only module cannot depend on process.env.API_KEY. At build time, Next.js either strips the reference or leaves code that resolves to undefined in the browser. The important point is that the browser bundle cannot access the live server environment.
This is easiest to see in code.
ts// app/components/Widget.tsx 'use client'; export function Widget() { const apiKey = process.env.API_KEY; return <div>{apiKey}</div>; }
This is wrong for two reasons:
API_KEYis server-only.- The component runs in the browser because of
'use client'.
The browser does not have your deployment environment. It only has the compiled assets that Vercel serves.
How to separate server, build, and client values
The fix is not to “make process.env work” in the browser. The fix is to move each read to the correct boundary.
Server-only reads stay on the server
Read secrets in server components, server actions, route handlers, or other Node.js-only code.
ts// app/api/config/route.ts export async function GET() { const apiKey = process.env.API_KEY; if (!apiKey) { return new Response('Missing API_KEY', { status: 500 }); } return Response.json({ ok: true }); }
This is valid because the route handler runs on the server, not in the browser.
A server component can also read server-only environment variables:
tsx// app/page.tsx export default function Page() { const secret = process.env.API_KEY; return <main>{secret ? 'Configured' : 'Missing'}</main>; }
That value never reaches client JavaScript unless you explicitly pass it down.
Client code uses props or public variables
Client components should receive data from the server as props, or read only NEXT_PUBLIC_ variables.
ts// app/components/ApiStatus.tsx 'use client'; type Props = { apiBaseUrl: string; }; export function ApiStatus({ apiBaseUrl }: Props) { return <p>{apiBaseUrl}</p>; }
Then pass the value from a server component:
tsx// app/page.tsx import { ApiStatus } from './components/ApiStatus'; export default function Page() { const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL ?? ''; return <ApiStatus apiBaseUrl={apiBaseUrl} />; }
This keeps the server-only secret off the client, while allowing a non-secret public URL into the browser.
Why NEXT_PUBLIC_ exists
Next.js needs an explicit opt-in for client exposure. Without a prefix, an environment variable should be treated as private.
Example .env.local:
envAPI_KEY=super-secret NEXT_PUBLIC_API_BASE_URL=https://api.example.com
The first value is for server code only.
The second value can be embedded in client bundles because it is not a secret.
If you write this in client code:
tsconst baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL;
Next.js can replace it during the build so the browser bundle contains the literal string. If you write this instead:
tsconst secret = process.env.API_KEY;
the browser bundle must not contain the secret, so the value is unavailable.
Build-time substitution versus runtime access
This difference matters on Vercel because deployment builds and runtime requests are separate phases.
Build-time
During next build, Next.js compiles the application. Some environment values are read at build time and embedded into generated output.
That means changing a value in the Vercel dashboard after build does not affect already built client assets unless the application is rebuilt and redeployed. For public variables, the value in the bundle is whatever existed during the build.
Runtime server
On Vercel, server code runs in serverless or edge environments at request time. Server-side reads can use deployment environment variables available at runtime, depending on the function type and environment setup.
A server action or route handler can read process.env.API_KEY when the request executes.
Client runtime
The browser only sees shipped assets. It cannot call process.env directly. It cannot read the Vercel project environment. It can only use values that were embedded or fetched from an API.
That is why server secrets must never be referenced in client modules.
How to move a server-only read out of client code
If a client component currently reads a server-only variable, move the read to a server component or route handler.
Before
tsx'use client'; export function CheckoutButton() { const stripeSecret = process.env.STRIPE_SECRET_KEY; async function handleClick() { await fetch('/api/pay', { method: 'POST', headers: { Authorization: `Bearer ${stripeSecret}`, }, }); } return <button onClick={handleClick}>Pay</button>; }
This is invalid. The browser cannot hold a Stripe secret key.
After
tsx// app/checkout/page.tsx import { CheckoutButton } from './CheckoutButton'; export default function CheckoutPage() { const publishableKey = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? ''; return <CheckoutButton publishableKey={publishableKey} />; }
tsx// app/checkout/CheckoutButton.tsx 'use client'; type Props = { publishableKey: string; }; export function CheckoutButton({ publishableKey }: Props) { async function handleClick() { await fetch('/api/pay', { method: 'POST', headers: { 'X-Publishable-Key': publishableKey, }, }); } return <button onClick={handleClick}>Pay</button>; }
Server-only logic stays in the API route:
ts// app/api/pay/route.ts export async function POST() { const stripeSecret = process.env.STRIPE_SECRET_KEY; if (!stripeSecret) { return new Response('Missing STRIPE_SECRET_KEY', { status: 500 }); } // Use stripeSecret on the server only. return Response.json({ ok: true }); }
Do not send secrets to the browser just to make client code “simpler”. Use the browser only for user interaction and display, then send requests to server code that can safely use private variables.
How to verify what ends up in the bundle
You should inspect the output instead of assuming a variable is hidden.
1. Build the app
Run a production build.
bashnpm run build
Or with pnpm:
bashpnpm build
2. Search the generated output
Look for the variable name or secret in the build artifacts.
bashgrep -R "API_KEY" .next | head
Or search for a known public variable:
bashgrep -R "NEXT_PUBLIC_API_BASE_URL" .next | head
If a secret string appears in .next/static, .next/server, or other client-facing assets, it has been embedded somewhere it should not be.
3. Inspect browser chunks
For app router builds, inspect .next/static/chunks. Any code used by a client component can end up there.
bashgrep -R "super-secret" .next/static/chunks
If that returns a match, the secret is in client-distributable JavaScript.
4. Use source maps when needed
If source maps are enabled, inspect the generated client chunk and confirm whether the value was inlined or replaced.
This helps distinguish:
- a value embedded into a client chunk
- a value read only on the server
- a value that never reached the build output
Common patterns that cause the bug
Using process.env directly inside a client component
tsx'use client'; export function Search() { const url = process.env.SEARCH_API_URL; return <div>{url}</div>; }
This is the most direct failure mode. Move the value to a server component or convert it to NEXT_PUBLIC_SEARCH_API_URL only if it is intentionally public.
Passing a secret through props from a server component
tsx// app/page.tsx import { Client } from './Client'; export default function Page() { const secret = process.env.API_KEY ?? ''; return <Client secret={secret} />; }
This compiles, but it still leaks the secret to the browser because props to a client component are serialized and sent to the client. Do not do this.
Importing server-only modules into client code
ts// lib/config.ts export const apiKey = process.env.API_KEY;
tsx'use client'; import { apiKey } from '@/lib/config';
Even if the environment read is moved to a shared file, importing that file into a client component makes it part of the client bundle path. Shared code must not contain secret reads unless it is guaranteed to stay server-only.
The correct mental model
Treat environment variables as belonging to one of these layers:
- build-time constants that can be compiled into JavaScript
- runtime server configuration that never leaves the server
- explicitly public values prefixed with
NEXT_PUBLIC_
If the browser needs a value, that value is public by definition. If it must stay secret, the browser must not receive it at all.
For app router projects, a practical rule is simple:
- use
process.env.*only in server components, route handlers, server actions, and Node.js-only utility code - use
process.env.NEXT_PUBLIC_*only for non-sensitive browser-visible values - use props or API calls to move data from server code to client code
Practical takeaway
Prefer server-side reads for secrets and NEXT_PUBLIC_ only for values that are safe to expose. Keep process.env.API_KEY out of client components, verify the built output with grep -R in .next, and move any needed browser data through server components or API routes instead of embedding private environment variables into shipped code.