cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

Server-only code and environment variables

Keep secrets on the server in Next.js: server-only and client-only, .env files and their order, NEXT_PUBLIC_ inlining, and how to spot a leaked key.

By the end, you can
  • 01
    Keep server code out of the browserMark modules with server-only and client-only, and read the build errors they give when they're imported on the wrong side.
  • 02
    Know where each value comes fromRead .env files in Next.js's order, tell NEXT_PUBLIC_ variables from server ones, and say when each one is read.
  • 03
    Spot a leaked secretFind a key in the visible page, the RSC payload or the JavaScript, and fix the code that put it there.

A secret key that stays on the server

The tea shop asks its supplier how many cups of each tea are left. The supplier gave the shop a key, a secret string like a password, and only answers requests that carry it. Anyone who has the key can ask the supplier as if they were the shop. In Server Components in Next.js, a Server Component read that key, and the browser got only the stock. Three things keep it that way: a line that stops server code from reaching the browser, environment variables that hold the key outside your code, and care about what your components render and pass.

The key lives in an environment variable: a named setting that a running program gets from outside its code. Node.js, which runs Next.js on the server, puts them in process.env. A .env file at the top of the project is where you write them while you work: Next.js reads it into process.env when it starts. This example is a real Next.js 16.4.3 project, built with next build and run with next start:

The Green tea page, asking the supplier with a key from .envNext.js
app/lib/
lib/supplier.ts
import "server-only"; // A stand-in for the supplier's stock service. A real one would send the key with each request.export async function getStock(id: string) {  const key = process.env.SUPPLIER_KEY;  if (!key) return null;  const stock: Record<string, number> = { green: 12, mint: 0, lemon: 5 };  return stock[id];}
Recorded from Next.js 16.4.3
localhost:3000/Tea shop
Green tea

12 in stock

The page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.

Click .env in the file tree: line 2 sets SUPPLIER_KEY to sk_tea_7Hq2xP, and line 1 is a comment. lib/supplier.ts is shared code beside app, so it has no address. Line 1, import "server-only", marks it as code for the server only. Line 5 reads the key, line 6 gives up without one, and lines 7 and 8 stand in for the supplier's answer. In app/page.tsx, line 4 waits for getStock("green"), and line 8 shows the result.

Open the Terminal tab. The line - Environments: .env, near the top, says Next.js loaded the file. The page built as ○ /. The Network tab has two responses. The first is the page's HTML: the visible page shows 12 in stock and no key. The second is the page's RSC payload, which the recording asked for with the rsc: 1 header: the description of what the Server Components rendered, with the props for any Client Components. On a first visit, Next.js sends that same payload inside the HTML response, in inline scripts. It holds 12, and no key. The recording also searched the JavaScript files the page loads: neither sk_tea_7Hq2xP nor the name SUPPLIER_KEY is in them.

Three windows. The .env file holds SUPPLIER_KEY and its value, sk_tea_7Hq2xP. An arrow goes to the server, where lib/supplier.ts reads the key and getStock returns 12. An arrow goes to the browser, which only gets Green tea and 12 in stock..envSUPPLIER_KEYsk_tea_7Hq2xPloaded on the serverserverlib/supplier.tsreads the keygetStock → 12code and key stay herebrowserGreen tea12 in stockonly the page
Next.js loads .env into process.env on the server. lib/supplier.ts reads the key there and returns 12. The browser gets the page with 12 in stock, and nothing else: not the key, and not the code that read it.
Real-life analogyThe shop's back room and its window

The shop keeps the supplier's key in a safe in the back room, and a sign on the door keeps customers out. The price tags in the window are printed before the shop opens, for everyone to read. A key left on the counter, or taped to a box handed to a customer, walks out of the shop, safe or no safe.

In real life: The safe in the back room, where the supplier's key is kept
In Next.js: A plain environment variable, read on the server
In real life: A sign on the back-room door: staff only
In Next.js: import "server-only" at the top of a module
In real life: The price tags in the shop window, printed before opening
In Next.js: NEXT_PUBLIC_ values, copied into the JavaScript during the build
In real life: Leaving the key on the counter, or taping it to a box you hand out
In Next.js: Rendering the key, or passing it in a prop
The main idea

Secrets live in environment variables without a prefix, and are read only by server-only code. Next.js copies only NEXT_PUBLIC_ variables into the browser's JavaScript. Even so, a secret leaks if a component renders it or passes it to a Client Component, so check the visible page, the RSC payload and the JavaScript.

server-only and client-only

Nothing stops another file from importing lib/supplier.ts. If a Client Component imports it, the module becomes client code too, the way Composing Server and Client Components showed for components. Try it: here the supplier's module is imported by the page, then by a Client Component, then by the same Client Component with the server-only line in place.

Playground: who imports the supplier's module?
The Green tea page, with lib/supplier.ts imported this wayNext.js
app/lib/
lib/supplier.ts
import "server-only"; // A stand-in for the supplier's stock service. A real one would send the key with each request.export async function getStock(id: string) {  const key = process.env.SUPPLIER_KEY;  if (!key) return null;  const stock: Record<string, number> = { green: 12, mint: 0, lemon: 5 };  return stock[id];}
Recorded from Next.js 16.4.3
localhost:3000/Tea shop
Green tea

12 in stock

The page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.

Try it yourself
Imported by the page

Only the page, a Server Component, imports lib/supplier.ts. The build passes, the page shows 12 in stock, and the browser's JavaScript contains neither the key nor the name SUPPLIER_KEY.

Every version was built with next build from Next.js 16.4.3; a failed build shows only the Terminal. Reset goes back to the first version.

What happened in each version:

  • Imported by the page: the page is a Server Component, so the module stays on the server. This is the overview's example.
  • Imported by a Client Component, without server-only: the build passes. StockBadge calls getStock in an Effect, in the browser. The recording found the name SUPPLIER_KEY in the page's JavaScript, but not the key: in the browser, process.env.SUPPLIER_KEY has no value, so getStock returns null and the page says Stock unknown. Nothing logged an error. The secret didn't leak, but the module's code did, and the page quietly stopped working.
  • With server-only: the build stops. The recording printed two messages for line 1. The second says it plainly: 'server-only' cannot be imported from a Client Component module. It should only be used from a Server Component. (The first one mentions the Pages Router, which this app doesn't use.) The import trace under it lists ./app/stock-badge.tsx [Client Component Browser]: the file to fix.

So import "server-only" turns a quiet mistake into a build error that names the file. The client-only line does the opposite, for code that only works in the browser, like code that uses localStorage:

A page that calls code marked client-onlyNext.js
app/lib/
app/page.tsx
import { rememberTea } from "@/lib/recent"; export default function GreenTeaPage() {  rememberTea("green");  return (    <main>      <h1>Green tea</h1>    </main>  );}
next build failed · Next.js 16.4.3
$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled   Creating an optimized production build ... > Build error occurredError: Turbopack build failed with 2 errors:./lib/recent.ts:1:1Error: You're importing a component that imports client-only. It only works in a Client Component but none of its parents are marked with "use client", so they're Server Components by default.    Learn more: https://nextjs.org/docs/app/building-your-application/rendering> 1 | import "client-only";    | ^^^^^^^^^^^^^^^^^^^^^  2 |  3 | // Remembers the teas this visitor looked at, in the browser's localStorage.  4 | export function rememberTea(id: string) { Ecmascript file had an error Import trace:  Server Component:    ./lib/recent.ts    ./app/page.tsx ./lib/recent.ts:1:1Error: 'client-only' cannot be imported from a Server Component module> 1 | import "client-only";    | ^^^^^^^^^^^^^^^^^^^^^  2 |  3 | // Remembers the teas this visitor looked at, in the browser's localStorage.  4 | export function rememberTea(id: string) { It should only be used from a Client Component. Import trace:  Server Component:    ./lib/recent.ts    ./app/page.tsx     at ignore-listed frames

Line 1 of app/page.tsx imports rememberTea from lib/recent.ts, whose line 1 is import "client-only", and line 4 calls it while the page renders, on the server, where there's no localStorage. The build stops: 'client-only' cannot be imported from a Server Component module.

In Next.js you don't have to install either package. Next.js handles both imports itself and doesn't use the packages' own code: the recorded project's package.json is the one create-next-app writes, and it lists neither. If your linter complains about a package that isn't installed, install it with npm install server-only.

Two lines that guard a module
Line at the top of the modulePut it inThe build stops when
import "server-only"Code that reads secrets, a database, or other server-only things.A Client Component's file imports the module, directly or through other files.
import "client-only"Code that needs the browser, like window or localStorage.A Server Component's file imports the module.

.env files and their load order

A project can have several .env files: one for every environment, one for each kind of environment, and private copies of each. Here the tea shop has five, and four variables. Each value says which file it's from.

Five .env files, four variablesNext.js
app/
app/page.tsx
export default function Home() {  return (    <main>      <h1>Tea shop</h1>      <p>{process.env.WELCOME}</p>      <p>{process.env.CURRENCY}</p>      <p>{process.env.SALE}</p>      <p>{process.env.SUPPORT}</p>    </main>  );}
Recorded from Next.js 16.4.3
localhost:3000/Tea shop
Tea shop

Welcome (from .env.production.local)

EUR (from .env.local)

Two for one (from .env.production)

help@tea.example (from .env)

The page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.

Lines 5 to 8 of app/page.tsx show WELCOME, CURRENCY, SALE and SUPPORT. Click through the .env files in the tree, then open the Browser tab. Each variable came from a different file:

  • WELCOME is in all four files the build reads, and came from .env.production.local.
  • CURRENCY is in .env.local, .env.production and .env, and came from .env.local: EUR.
  • SALE came from .env.production. .env.development says Everything free, but the build never read it.
  • SUPPORT is only in .env, so it came from there.

For each variable, Next.js looks in a fixed order and stops at the first place that has it. The order depends on NODE_ENV, a variable that says which kind of environment this is. Unless it's already set, Next.js sets it to development for next dev and to production for next build and next start. Test runners set it to test. The Terminal tab's first lines show the files the build loaded, in that order: - Environments: .env.production.local, .env.local, .env.production, .env. Each row is one place, from first to last:

Where Next.js looks for a variable during next build
WhereWhen it's readIn this recording
The real environment, set by your shell or your hostEvery time. A value set there wins over every file.Not used in this example.
.env.production.localWith next build and next start. Kept out of git.WELCOME came from here.
.env.localAlways, except when NODE_ENV is test. Kept out of git.CURRENCY came from here.
.env.productionWith next build and next start.SALE came from here.
.envAlways, last.SUPPORT came from here.
.env.developmentOnly with next dev.Not loaded: the build doesn't list it.
Five places in a column, looked up from top to bottom for each variable: the real environment, .env.production.local, .env.local, .env.production and .env. Beside each, the variable that came from it in the recording: WELCOME from .env.production.local, CURRENCY from .env.local, SALE from .env.production, SUPPORT from .env. Below them, .env.development is crossed out: next build doesn't load it.real environmentchecked first: wins over every file.env.production.localWELCOME came from here.env.localCURRENCY came from here.env.productionSALE came from here.envSUPPORT came from here.env.developmentonly for next dev: skipped
For each variable, Next.js looks from the top down and stops at the first place that has it. During next build and next start, NODE_ENV is production, so .env.development isn't read at all.

A variable already set in the real environment wins over every file: one your shell sets, or one your host sets for the server. That's how a hosting service gives the production server its secrets without any file. With NODE_ENV set to test, .env.local is skipped, so tests get the same values on every machine.

Keep .env files out of git

The .gitignore that create-next-app writes has the line .env* (its template is the gitignore file in create-next-app's app template), so git never sees your secrets. Keep it. Next.js reads the files from the project's top folder, even when your code is in src. The order above is the list in lines 146 to 156 of packages/next-env/index.ts, the code Next.js uses to load them.

NEXT_PUBLIC_ variables are inlined at build

Some values have to reach the browser. The shop counts visits with an analytics service, and the browser sends the shop's analytics id with each one. That id isn't a secret. Give a variable a name that starts with NEXT_PUBLIC_, and Next.js inlines it: during next build, in the code that goes to the browser, it replaces process.env.NEXT_PUBLIC_ANALYTICS_ID with the value itself, as text.

A Client Component that shows the public analytics idNext.js
app/
app/stats.tsx
"use client"; export default function Stats() {  return <footer>Visits counted for {process.env.NEXT_PUBLIC_ANALYTICS_ID}</footer>;}
Recorded from Next.js 16.4.3
localhost:3000/Tea shop
Green tea

The page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.

The .env file now has two variables: SUPPLIER_KEY and NEXT_PUBLIC_ANALYTICS_ID. Line 4 of app/stats.tsx, a Client Component, reads the second. The page shows Visits counted for tea-stats-204, on the server and again in the browser, and Chrome logged no error. The recording searched the page's JavaScript and found tea-stats-204, but not the name NEXT_PUBLIC_ANALYTICS_ID: the name was replaced. It didn't find sk_tea_7Hq2xP. A variable without the prefix is never copied in.

Two rows. Top: process.env.NEXT_PUBLIC_ANALYTICS_ID in your code becomes the text tea-stats-204 in the JavaScript files, during next build. Bottom: process.env.SUPPLIER_KEY stays process.env.SUPPLIER_KEY, which has no value in the browser.In your codeIn the JavaScript filesnext buildprocess.env.NEXT_PUBLIC_ANALYTICS_ID"tea-stats-204"process.env.SUPPLIER_KEYprocess.env.SUPPLIER_KEYno value in the browser
During next build, process.env.NEXT_PUBLIC_ANALYTICS_ID, written out directly in code that goes to the browser, is replaced by its value: the recorded JavaScript contains tea-stats-204. SUPPLIER_KEY is left as it is, and has no value in the browser.

Next.js's docs promise this replacement for process.env.NEXT_PUBLIC_… written out directly, so write it that way. A name that can't be known during the build, like process.env[variable] with the name passed in as a prop, isn't replaced, and has no value in the browser. Server code can read NEXT_PUBLIC_ variables too: on the server they're ordinary environment variables, and a server-only module that reads one sends nothing to the browser. Next.js collects them in getNextPublicEnvironmentVariables, which makes a replacement for each variable whose name starts with NEXT_PUBLIC_.

Build-time and runtime variables

Inlining happens during the build, so a NEXT_PUBLIC_ value is fixed when next build runs. Changing it afterwards changes nothing until you build again. Server variables are read when the code that uses them runs. With Cache Components on, as new apps have it and every example here does, Next.js prerenders during the build every part of a page that doesn't wait for a request, so that can be during the build too. Try two versions of a home page that shows WELCOME, with the same .env:

Playground: when is WELCOME read?
The home page, reading WELCOME this wayNext.js
app/
app/page.tsx
export default function Home() {  return (    <main>      <h1>Tea shop</h1>      <p>{process.env.WELCOME}</p>    </main>  );}
Recorded from Next.js 16.4.3
$ next build▲ Next.js 16.4.3 (Turbopack)- Environments: .env- Cache Components enabled- Partial Prefetching enabled   Creating an optimized production build ...✓ Compiled successfully  Running TypeScript ...  Finished TypeScript ...  Collecting page data ...✓ Generating static pages (3/3)  Finalizing page optimization ... Route (app)┌ ○ /└ ○ /_not-found ○  (Static)  prerendered as static content
Try it yourself
Read while rendering

Line 5 reads WELCOME while the page renders. Nothing waits for a request, so the build reads it and saves the HTML: ○ /. Every visitor gets the value from build time, until the next build.

Both versions were built with next build from Next.js 16.4.3, with the same .env file. Only app/page.tsx changes. Reset goes back to the first version.

The first version reads WELCOME while the page renders, and nothing in it waits for a request. So the build renders it and saves the HTML: ○ /, prerendered as static content. The value is in that saved page, and Cache-Control: s-maxage=31536000 lets caches keep it for a year. Change WELCOME on the server after the build, and visitors still get the old value. The second version calls connection() from next/server first, which waits for a real request. Now the build can't read WELCOME for that part, so it builds ◐ /: the shell with Loading…, and WELCOME read for each request, from the running server's environment. Its Network tab shows Cache-Control: private, no-cache, no-store….

When each kind of variable is read
Kind of variableRead whenChanges after the build?
NEXT_PUBLIC_…, in code that goes to the browserDuring next build: the value is copied into the JavaScript.No. Build again to change it.
A server variable, in a page prerendered as ○During next build, while the page is prerendered.No. Build again to change it.
A server variable, after connection(), cookies() or headers(), inside SuspenseFor each request, on the server.Yes. The running server's value is used.

This matters when one build runs in several places, like a staging server and the real one. Next.js's docs suggest reading such values on the server, at request time, and sending the browser what it needs. cookies() and headers() wait for a request too; Request-time APIs teaches them.

Spotting a leaked secret

server-only and the prefix guard the code and the JavaScript. They don't guard what your components do with a value. Here are four ways the supplier's key gets out, and one fixed version. Before you open each one, guess where the key will be: in the visible page, in the RSC payload, or in the JavaScript. The Network tab opens on each version's RSC payload; the HTML is the first response.

Playground: where did the key get out?
The Green tea page, with this version of the supplier's codeNext.js
app/lib/
app/page.tsx
import { getStock } from "@/lib/supplier"; export default async function GreenTeaPage() {  const stock = await getStock("green");  return (    <main>      <h1>Green tea</h1>      <p>{stock} in stock</p>      <p>Debug: key {process.env.SUPPLIER_KEY}</p>    </main>  );}
Recorded from Next.js 16.4.3

GET /?_rsc=200

Sent with the request
rsc 1
Response headers
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache HIT
x-nextjs-prerender 1
x-nextjs-stale-time 300
Cache-Control s-maxage=31536000
Content-Type text/x-component

The body that arrived, as it arrived: the RSC payload, one row per line

#1:"$Sreact.fragment"
2:"/_next/static/chunks/1tq6ppe__m8m-.js"
3:I[39756,["$2"],"default"]
4:I[37457,["$2"],"default"]
8:I[97367,["$2"],"OutletBoundary"]
9:"$Sreact.suspense"
c:"ViewportBoundary"
d:I[97367,["$2"],"$c"]
f:"MetadataBoundary"
10:I[97367,["$2"],"$f"]
13:I[68027,["$2"],"default",1]
5:X
b:X
6:[["children",{"s":"__PAGE__","h":514,"d":{"r":["$","$1","c",{"children":["$L7",null,["$","$L8",null,{"children":["$","$9",null,{"name":"Next.MetadataOutlet","children":"$@a"}]}]]}],"p":true,"v":"$b"}}]]
12:X
14:X
15:X
0:{"P":null,"c":["",""],"q":"","i":false,"t":{"t":{"s":"","h":530,"d":{"r":["$","$1","c",{"children":[[["$","script","script-0",{"src":"/_next/static/chunks/1tq6ppe__m8m-.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L3",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L4",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]}]]}],"p":true,"v":"$5"},"c":"$Q6"},"h":{"r":["$","$1","h",{"children":[null,["$","$Ld",null,{"children":"$Le"}],["$","$L10",null,{"children":[["$","div",null,{"hidden":true,"children":["$","$9",null,{"name":"Next.Metadata","children":"$L11"}]}],null]}],null]}],"p":true,"v":"$12"}},"m":"$undefined","G":["$13",[]],"S":true,"r":"$14","s":"$15","a":"$@16","l":"$undefined","p":"$undefined","d":"$undefined","b":"Mzbuc8ht6qo9g2YWRiFbn","u":"$@17"}
15:300
7:["$","main",null,{"children":[["$","h1",null,{"children":"Green tea"}],["$","p",null,{"children":[12," in stock"]}],["$","p",null,{"children":["Debug: key ","sk_tea_7Hq2xP"]}]]}]
e:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
a:null
11:[["$","title","0",{"children":"Tea shop"}]]
15:C
12:C
14:C
b:C
5:C
17:false
16:null
Try it yourself
Rendered by a Server Component

Line 9 renders the key. A Server Component's code stays on the server, but what it renders doesn't: the key is on the visible page, and in the RSC payload, for everyone.

Every version was built with next build from Next.js 16.4.3 and run with next start. The Network tab opens on the RSC payload, recorded with rsc: 1; the page's HTML is the first response. Reset goes back to the first version.

Where the key ended up:

  • Rendered by a Server Component. A Server Component's code stays on the server, but its output doesn't: the visible page says Debug: key sk_tea_7Hq2xP, and the RSC payload carries the same text. A debug line like this is easy to forget.
  • Passed in a prop. The badge shows only Supplied by Green Hills, but props for a Client Component travel whole. The visible page has no key, but the RSC payload has "key":"sk_tea_7Hq2xP" in it. On a first visit, that payload is inside the HTML response, in inline scripts: anyone who views the page's source can find it.
  • Read in a Client Component. In the browser, process.env.SUPPLIER_KEY has no value, so it can seem harmless. But the build also renders Client Components on the server, where it has one: the visible page says Debug: key sk_tea_7Hq2xP. The RSC payload doesn't have it: for a Client Component with no props, the payload only points at the component's code. Then React runs the component in the browser, where it expects the same result as the server's HTML. It gets a different one, and reports error #418, a hydration mismatch.
  • Renamed NEXT_PUBLIC_SUPPLIER_KEY, so the browser could call the supplier itself. It works, and the build copies the key into the JavaScript, where anyone can read it.
  • Fixed: line 7 passes only name="Green Hills". The recordings found the key nowhere.

Each row is one version, and whether the recording found the key in each place. The visible page is what the browser shows. The RSC payload was recorded with rsc: 1; on a first visit it also arrives inside the HTML response.

Where the recordings found the key
How the key got outIn the visible page?In the RSC payload?In the JavaScript files?
A Server Component renders itYes: Debug: key sk_tea_7Hq2xP.Yes: the same rendered text.No.
It rides along in a propNo.Yes: "key":"sk_tea_7Hq2xP".No.
A Client Component reads SUPPLIER_KEYYes: the build rendered it on the server.No: the payload only points at the component's code.No. Only the name SUPPLIER_KEY.
It's renamed NEXT_PUBLIC_SUPPLIER_KEYNo.No.Yes: the build copied it in.
Only the name is passedNo.No.No.
Three windows, each with the key sk_tea_7Hq2xP inside. The visible page: a component rendered it. The RSC payload: a Server Component rendered it, or it was passed in a prop. The JS files: it was named NEXT_PUBLIC_.pagesk_tea_7Hq2xPa componentrendered itpayloadsk_tea_7Hq2xPrendered, orpassed in a propJS filessk_tea_7Hq2xPit was namedNEXT_PUBLIC_
The three places to look, and what put the key there in this lesson's recordings. A key a Server Component renders is in the visible page and in the RSC payload; a key passed in a prop is only in the payload; a Client Component that reads it during the build puts it in the visible page.

The fix is always the same: decide what the browser needs, and send only that. For the stock, that's a number. Next.js's data security guide goes further: keep every read of secrets and private data in one server-only module, a data access layer, which returns only the fields a page needs (Authorization and the data access layer).

The Building real apps stage comes back to security.

Checking the tea shop before you deploy

Before you deploy the shop, check the three places a secret can show up. Take the first characters of each secret, like sk_tea_, and search for them:

  • The HTML response. Open each page, view its source, and search. On a first visit, the RSC payload is in the response's inline scripts, so this checks the visible page, rendered values and props together.
  • The RSC payload of a navigation. In the browser's developer tools, open the Network panel, click a link, and search the responses whose type is text/x-component.
  • The JavaScript. After next build, search the files the browser downloads, which are under .next/static. This is the same search the recordings in this lesson made:
Search the built JavaScript for the keyShell
npm run build
grep -rl "sk_tea_" .next/static

Line 1 builds the app. Line 2 searches every file under .next/static and prints the names of those that contain sk_tea_. No output means no file has it. Then sort the shop's settings: which ones may get the prefix?

Which variables may be NEXT_PUBLIC_?
  • The key the supplier gave the shop
  • The database's password
  • The payment service's secret key
  • The secret that signs sign-in cookies
  • The analytics id the browser sends with each visit
  • The payment service's publishable key
  • The shop's public address, for share links
Try it yourself
0 of 7 correct

Sort each setting: must it stay on the server, or is it fine for everyone to read in the JavaScript?

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "A Client Component can't leak a server variable, because it has no value in the browser." No. The build also renders Client Components on the server, where the variable has a value, and the recorded visible page had the key in it.
  • "A file without an address, like lib/supplier.ts, is safe." No. A Client Component that imports it sends its code to the browser. import "server-only" turns that into a build error.
  • "Only what a component shows is sent." No. Props for a Client Component are sent whole, in the RSC payload, including fields it never shows.
  • "NEXT_PUBLIC_ values are read when the page loads." No. They're copied into the JavaScript during next build, and stay that way until the next build.
  • ".env.local always wins." No. During next build, .env.production.local comes before it, and a variable set in the real environment wins over every file.
  • "You must install server-only for it to work." No. Next.js handles the import itself. Install the package only if your linter asks for it.

Practice exercises

These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.

Exercise 1 · Warm-upPick the winning file

The shop has these three files, and a .env.production.local that only sets WELCOME. The page renders process.env.CURRENCY. What does it show after next build?

Starter codeText
# .env.local
WELCOME=Welcome (from .env.local)
CURRENCY=EUR (from .env.local)

# .env.production
WELCOME=Welcome (from .env.production)
CURRENCY=USD (from .env.production)
SALE=Two for one (from .env.production)

# .env
WELCOME=Welcome (from .env)
CURRENCY=USD (from .env)
SALE=No sale today (from .env)
SUPPORT=help@tea.example (from .env)

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upFind the public id

    .env sets NEXT_PUBLIC_ANALYTICS_ID=tea-stats-204. After next build, do the JavaScript files this page loads contain tea-stats-204?

    Starter codeTSX
    "use client";
    
    export default function Stats() {
      return <footer>Visits counted for {process.env.NEXT_PUBLIC_ANALYTICS_ID}</footer>;
    }
    

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeFollow the import trace

      With import "server-only" in lib/supplier.ts, the build stops: 'server-only' cannot be imported from a Client Component module. Which file does the import trace name as the Client Component, and how would you fix it?

      Starter codeTSX
      "use client";
      
      import { useEffect, useState } from "react";
      import { getStock } from "@/lib/supplier";
      
      export default function StockBadge() {
        const [stock, setStock] = useState<number | null>(null);
        useEffect(() => {
          getStock("green").then(setStock);
        }, []);
        return <p>{stock === null ? "Stock unknown" : `${stock} in stock`}</p>;
      }
      

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeSend only what's needed

        This page leaks the key in the RSC payload, although the badge never shows it. What should it pass to SupplierBadge instead?

        Starter codeTSX
        import SupplierBadge from "./supplier-badge";
        
        export default function GreenTeaPage() {
          const supplier = { name: "Green Hills", key: process.env.SUPPLIER_KEY };
          return (
            <main>
              <h1>Green tea</h1>
              <SupplierBadge supplier={supplier} />
            </main>
          );
        }
        

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeBring back the analytics id

          The recorded page first says Visits counted for tea-stats-204, then the browser shows Visits counted for nobody, and React reports error #418. What should line 4 read instead of process.env[variable]?

          Starter codeTSX
          "use client";
          
          export default function Stats({ variable }: { variable: string }) {
            return <footer>Visits counted for {process.env[variable] ?? "nobody"}</footer>;
          }
          

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeRemember the tea in the browser

            The build stops: 'client-only' cannot be imported from a Server Component module. Where should rememberTea("green") be called instead?

            Starter codeTSX
            import { rememberTea } from "@/lib/recent";
            
            export default function GreenTeaPage() {
              rememberTea("green");
              return (
                <main>
                  <h1>Green tea</h1>
                </main>
              );
            }
            

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask where the code runs, when the value is read, and what reaches the browser.

              The server-only code and environment variables quiz · 7 questionsScore: first tries count
              1. Question 1 of 7The shop's .env has SUPPLIER_KEY and NEXT_PUBLIC_ANALYTICS_ID, and a Client Component renders the analytics id. What's in the browser's JavaScript after the build?

                Choose an answer to see the explanation.

              2. Question 2 of 7.env, .env.local, .env.production and .env.production.local all set WELCOME. Which value does next build use?

                Choose an answer to see the explanation.

              3. Question 3 of 7A Client Component imports a module that starts with import "server-only". What happens?

                Choose an answer to see the explanation.

              4. Question 4 of 7A page renders process.env.WELCOME and nothing else reads the request. The build prints ○ /. When is WELCOME read?

                Choose an answer to see the explanation.

              5. Question 5 of 7This Client Component renders the supplier's key. Where does the key end up?

                Read the code, then predictTSX
                "use client";
                
                export default function SupplierDebug() {
                  return <p>Debug: key {process.env.SUPPLIER_KEY}</p>;
                }
                

                Choose an answer to see the explanation.

              6. Question 6 of 7This page passes supplier to a Client Component that only shows supplier.name. Does the key reach the browser?

                Read the code, then predictTSX
                import SupplierBadge from "./supplier-badge";
                
                export default function GreenTeaPage() {
                  const supplier = { name: "Green Hills", key: process.env.SUPPLIER_KEY };
                  return (
                    <main>
                      <h1>Green tea</h1>
                      <SupplierBadge supplier={supplier} />
                    </main>
                  );
                }
                

                Choose an answer to see the explanation.

              7. Question 7 of 7You build the shop once and run the same build for staging and for production, each with its own NEXT_PUBLIC_API_URL. What does the browser get?

                Choose an answer to see the explanation.

              Key takeaways

              • Secrets go in environment variables without a prefix, read by modules that start with import "server-only". A Client Component that imports such a module stops the build.
              • import "client-only" guards browser-only code the same way. Next.js handles both imports without the packages installed.
              • Next.js reads .env files from the top folder and stops at the first place that has a variable: the real environment, then .env.production.local, .env.local, .env.production and .env during a build.
              • NEXT_PUBLIC_ values are copied into the code that goes to the browser during next build. Write process.env.NEXT_PUBLIC_… directly. They're public and fixed until the next build.
              • A page prerendered as ○ keeps the server values it read during the build. Read them after connection(), inside Suspense, to get each request's value.
              • A secret leaks through the visible page, the RSC payload (which also arrives inside the HTML response) or the JavaScript. Look in all three, and send the browser only what it needs.

              Remember the one-liner.
              Secrets stay in server variables and server-only code; whatever you render, pass or prefix with NEXT_PUBLIC_ is public.

              Coming next: CSS in Next.js, the first lesson of the Styling, assets and metadata stage: global styles, CSS Modules and Sass, and how Next.js orders and splits your CSS.

              CompleteFrontend Clear concepts. Working examples.