Next.js Environment Variables With App Router: What Actually Runs on the Server vs the Browser
A practical App Router guide to `.env` files, `NEXT_PUBLIC_`, server-only values, load order, and the mistakes that leak secrets or make developers think env vars are “not working.”
Why this topic keeps biting teams: Next.js environment variables are not complicated once you separate server-side execution from browser-side execution. Most confusion comes from forgetting where the code runs.
The core rule you must remember
In Next.js, not every environment variable belongs in the browser.
If a variable needs to be available in client-side JavaScript, it must be prefixed with:
NEXT_PUBLIC_If it does not have that prefix, treat it as server-only.
That one rule explains a huge number of “why is process.env.MY_VAR undefined?” bugs.
A clean .env.local example
DATABASE_URL=postgresql://app:secret@localhost:5432/app
OPENAI_API_KEY=sk-xxxxx
NEXT_PUBLIC_API_BASE_URL=http://localhost:3001Meaning:
DATABASE_URLis server-onlyOPENAI_API_KEYis server-onlyNEXT_PUBLIC_API_BASE_URLcan be bundled for browser code
Server component example
This is valid in a server component or route handler:
export default async function Page() {
const dbUrl = process.env.DATABASE_URL;
return <div>Server is configured: {dbUrl ? "yes" : "no"}</div>;
}That works because the code executes on the server.
Client component example
This will not work as expected:
"use client";
export default function ClientWidget() {
return <div>{process.env.OPENAI_API_KEY}</div>;
}That should stay undefined, and that is a good thing. A secret API key does not belong in browser code.
What should work:
"use client";
export default function ClientWidget() {
return <div>{process.env.NEXT_PUBLIC_API_BASE_URL}</div>;
}Why people think env vars are broken
Usually one of these reasons:
- they forgot
NEXT_PUBLIC_for browser use - they changed
.env.localwithout restarting the dev server - they are reading the variable from client code when it is server-only
- they committed code assuming all
process.env.*values behave the same
Restart matters more than people like
If you change an env file, restart the dev server:
npm run devor:
pnpm devdepending on the project.
Do not waste 20 minutes debugging stale process state.
A safe pattern for client apps
If your frontend needs to call a backend, expose the backend base URL publicly:
NEXT_PUBLIC_API_BASE_URL=https://api.example.comThen use it in client code:
"use client";
const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL;
export async function loadProfile() {
const response = await fetch(`${apiBase}/profile`);
return response.json();
}Keep secrets on the server. Let the browser know only what it must know.
Server-only route example
If you need to call a secret-backed API, do it in a route handler or server action:
export async function GET() {
const apiKey = process.env.OPENAI_API_KEY;
return Response.json({
configured: Boolean(apiKey),
});
}That protects the secret from browser exposure.
The deeper App Router lesson
App Router makes server and client code live closer together, which is powerful but also the source of confusion. The fix is to stop asking “does Next.js support env vars?” and start asking “where is this code executing?”
If the answer is “browser,” only NEXT_PUBLIC_ values belong there. If the answer is “server,” you can use private variables normally.
That separation is not a nuisance. It is one of the ways Next.js helps prevent people from leaking secrets accidentally.
The practical shortcut is this: if the file is a client component, or it touches browser-only APIs, stop expecting normal server secrets to be there. That one habit prevents a surprising amount of wasted debugging time.