In a Next.js App Router app, a component marked 'use client' reads process.env.API_TOKEN and logs undefined in the browser, while the identical read works inside a Server Component. Why, and what should the team do about it?
answer
- the browser has no process object
- substitution at build, not runtime lookup
- one prefix opts a variable in
- NEXT_PUBLIC_ declares it is not secret
- read on the server, pass the result
basics
~20 sNext only inlines environment variables whose names start with NEXT_PUBLIC_ into browser code, as a build-time text substitution. Anything else is simply absent in the browser and reads as undefined. If the value is a secret, keep the work on the server and pass only the result.
solid answer
~40 sThere is no `process` in the browser, so Next substitutes environment reads textually at build time for code that ends up in the client graph — and it only does that for names prefixed `NEXT_PUBLIC_`. `process.env.API_TOKEN` is not substituted, so it evaluates to undefined once the module is inside a client boundary; on the server it is a real runtime lookup and works. The fix depends on what the value is. If it is a secret, do not rename it — the token must stay server-side, so move the call that needs it into a Server Component, Route Handler or Server Action and pass only the result across. If it is genuinely public configuration, rename it with the `NEXT_PUBLIC_` prefix and accept that it will sit as a readable string in the shipped JavaScript.
code
tsx · 7 lines'use client'
export function Analytics() {
const siteId = process.env.NEXT_PUBLIC_ANALYTICS_ID
const token = process.env.API_TOKEN // undefined in the browser
return <span data-site={siteId} data-has-token={String(Boolean(token))} />
}go deeper
Remember that browser code only sees variables named with the NEXT_PUBLIC_ prefix, and that a secret reading as undefined on the client is correct behaviour rather than a bug to work around.
Explain the mechanism: Next substitutes those reads textually while building the client bundle, so the value is baked in, dynamic key lookups are not rewritten, and the server keeps a real runtime lookup.
Demonstrate the decision procedure — classify the value first, keep secrets behind server-side calls that return only results, and handle per-environment public config by reading it on the server and passing it down instead of rebuilding per environment.
Own the policy: what makes a variable eligible for the public prefix, how that decision is reviewed, and how you prevent a build artifact promoted across environments from carrying the wrong baked-in configuration.
## Two different `process.env` On the server, `process.env` is a real object that Node reads at runtime, so a Server Component can read anything the process was started with. In the browser there is no `process` at all. Next bridges that by rewriting the code: while building the client bundle it replaces occurrences of `process.env.NEXT_PUBLIC_SOMETHING` with the literal value of that variable. It is a textual substitution performed at build time, not a runtime lookup, and only names carrying the `NEXT_PUBLIC_` prefix are eligible. A read of any other variable in client code is left with nothing to resolve and evaluates to undefined. ```tsx 'use client' export function Analytics() { // Substituted at build time only because of the prefix return <span data-site={process.env.NEXT_PUBLIC_ANALYTICS_ID} /> } ``` ## Three consequences that catch teams out **Baked at build, not read at boot.** Because the value is stamped into the bundle when it is built, changing the variable in your hosting dashboard and restarting does nothing to already-built client code — it needs a rebuild. This bites hardest when one built artifact is promoted across staging and production: the client-side value travels with the artifact, so per-environment public config has to come from somewhere else. **The substitution is textual, so dynamic reads do not work.** `process.env[keyName]` in client code has no literal name to match and is not rewritten. Only a direct, statically written property access is substituted. **Public means public.** A `NEXT_PUBLIC_` variable becomes a plain string in a JavaScript file anyone can open in devtools. The prefix is not a convenience — it is a declaration that the value is not a secret. Nothing about it is scoped to your origin, your users, or your app. ## Deciding what to actually do Start from the value, not the error. *If it is a secret* — an API token, a database URL, a signing key — the undefined is the system working. Keep the code that uses it on the server: do the call inside a Server Component, a Route Handler, or a Server Action, and send only the result to the client. Renaming it with the public prefix to make the error go away is how tokens end up in public bundles. *If it is genuinely public* — an analytics site id, a public API base URL, a feature flag that is visible in the UI anyway — rename it with the `NEXT_PUBLIC_` prefix, and treat the rename as a decision you would be comfortable defending in a security review. *If it is public but must vary per environment at runtime* — read it in a Server Component, where `process.env` is a live lookup, and pass it down as a prop or through a provider. That way one build serves every environment and the value is resolved when the request is served rather than when the image was built. ## The boundary, not the file, decides the rules The rule keys on where the module ends up in the graph, not on where you wrote it or what it is named. A shared helper with no directive of its own that is imported by a client module is client code, and its environment reads follow client rules — undefined unless prefixed. The same helper imported only from Server Components reads the real environment. If one helper is imported from both sides and reads a non-prefixed variable, it will behave differently depending on the caller, which is a genuinely confusing bug to chase. Splitting such a helper, or taking the value as an argument instead of reading it, removes the ambiguity. ## Related leak paths worth naming Moving the secret across as a prop instead of via `process.env` does not help either: values passed from a Server Component into a Client Component travel in the payload the browser downloads, so they are just as readable. The boundary is the security perimeter, and the rule is the same wherever the value came from — decide whether it may be seen by the user, and if the answer is no, it never crosses. ## Answering the question in an interview A strong answer names the mechanism (build-time substitution, prefix opt-in), states plainly that the prefix is a publicity declaration rather than a workaround, and finishes with the decision procedure: secret stays server-side and only its result crosses; public config gets the prefix; per-environment public config is read on the server and passed down.
- You add the NEXT_PUBLIC_ prefix, redeploy with a new value, and the browser still shows the old one. Why?The value was inlined into the client bundle when it was built, so it travels with the artifact. Restarting or changing the variable in the hosting dashboard does not touch already-built JavaScript — it needs a rebuild, which is exactly why per-environment public config is better read on the server and passed down.
- A shared helper reads a non-prefixed variable and is imported from both a Server Component and a Client Component. What happens?It behaves differently depending on the caller: the real value on the server, undefined once it is pulled into the client graph. The fix is to stop reading the environment inside a shared helper — take the value as an argument, or split the helper so each side is explicit.
- Is passing the token from a Server Component into a Client Component as a prop any safer?No. Props crossing into a client component are serialized into the payload the browser downloads and can read. The perimeter is the boundary itself, not the transport — if a value must not be seen by the user, nothing about it crosses, only the result of using it.
saying these in an interview costs you the question
- Renames the secret with NEXT_PUBLIC_ to make it work
- Thinks NEXT_PUBLIC_ variables are hidden from users
- Expects a client-side env change to apply without a rebuild
- Believes process.env is populated at runtime in the browser
- Passes the secret as a prop instead, calling it safe