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.
- 01Keep 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.
- 02Know 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.
- 03Spot 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:
lib/supplier.tsimport "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];}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.
.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.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
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.
lib/supplier.tsimport "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];}12 in stock
The page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.
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.
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.StockBadgecallsgetStockin an Effect, in the browser. The recording found the nameSUPPLIER_KEYin the page's JavaScript, but not the key: in the browser,process.env.SUPPLIER_KEYhas no value, sogetStockreturnsnulland the page saysStock 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:
app/page.tsximport { rememberTea } from "@/lib/recent"; export default function GreenTeaPage() { rememberTea("green"); return ( <main> <h1>Green tea</h1> </main> );}$ 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 framesLine 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.
| Line at the top of the module | Put it in | The 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.
app/page.tsxexport 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> );}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:
WELCOMEis in all four files the build reads, and came from.env.production.local.CURRENCYis in.env.local,.env.productionand.env, and came from.env.local:EUR.SALEcame from.env.production..env.developmentsaysEverything free, but the build never read it.SUPPORTis 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 | When it's read | In this recording |
|---|---|---|
| The real environment, set by your shell or your host | Every time. A value set there wins over every file. | Not used in this example. |
.env.production.local | With next build and next start. Kept out of git. | WELCOME came from here. |
.env.local | Always, except when NODE_ENV is test. Kept out of git. | CURRENCY came from here. |
.env.production | With next build and next start. | SALE came from here. |
.env | Always, last. | SUPPORT came from here. |
.env.development | Only with next dev. | Not loaded: the build doesn't list it. |
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.
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.
app/stats.tsx"use client"; export default function Stats() { return <footer>Visits counted for {process.env.NEXT_PUBLIC_ANALYTICS_ID}</footer>;}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.
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:
app/page.tsxexport default function Home() { return ( <main> <h1>Tea shop</h1> <p>{process.env.WELCOME}</p> </main> );}$ 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 contentLine 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.
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….
| Kind of variable | Read when | Changes after the build? |
|---|---|---|
NEXT_PUBLIC_…, in code that goes to the browser | During 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 Suspense | For 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.
app/page.tsximport { 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> );}GET /?_rsc=200
- Sent with the request
rsc1
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-cacheHITx-nextjs-prerender1x-nextjs-stale-time300Cache-Controls-maxage=31536000Content-Typetext/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
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.
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_KEYhas no value, so it can seem harmless. But the build also renders Client Components on the server, where it has one: the visible page saysDebug: 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.
| How the key got out | In the visible page? | In the RSC payload? | In the JavaScript files? |
|---|---|---|---|
| A Server Component renders it | Yes: Debug: key sk_tea_7Hq2xP. | Yes: the same rendered text. | No. |
| It rides along in a prop | No. | Yes: "key":"sk_tea_7Hq2xP". | No. |
A Client Component reads SUPPLIER_KEY | Yes: 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_KEY | No. | No. | Yes: the build copied it in. |
| Only the name is passed | No. | No. | No. |
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:
npm run build
grep -rl "sk_tea_" .next/staticLine 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?
- 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
Sort each setting: must it stay on the server, or is it fine for everyone to read in the JavaScript?
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.localcomes 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.
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?
# .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)
EUR (from .env.local). .env.production.local doesn't set CURRENCY, so .env.local is the first file that does. The recorded page shows EUR (from .env.local).
.env sets NEXT_PUBLIC_ANALYTICS_ID=tea-stats-204. After next build, do the JavaScript files this page loads contain tea-stats-204?
"use client";
export default function Stats() {
return <footer>Visits counted for {process.env.NEXT_PUBLIC_ANALYTICS_ID}</footer>;
}
Yes. The name starts with NEXT_PUBLIC_, so next build replaced the expression with "tea-stats-204". The recording found tea-stats-204 in the page's JavaScript, and not the name.
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?
"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>;
}
import { getStock } from "@/lib/supplier";
import StockBadge from "./stock-badge";
export default async function GreenTeaPage() {
const stock = await getStock("green");
return (
<main>
<h1>Green tea</h1>
<StockBadge stock={stock} />
</main>
);
}
app/stock-badge.tsx: the trace lists ./app/stock-badge.tsx [Client Component Browser]. One fix: let the page, a Server Component, call getStock and pass the number to StockBadge as a prop. The recorded build passes, shows 12 in stock, and its JavaScript has neither the key nor SUPPLIER_KEY.
This page leaks the key in the RSC payload, although the badge never shows it. What should it pass to SupplierBadge instead?
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>
);
}
import SupplierBadge from "./supplier-badge";
export default function GreenTeaPage() {
return (
<main>
<h1>Green tea</h1>
<SupplierBadge name="Green Hills" />
</main>
);
}
Pass only the name, as name="Green Hills", and let SupplierBadge take { name }. The recorded RSC payload no longer contains the key, and the page still says Supplied by Green Hills.
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]?
"use client";
export default function Stats({ variable }: { variable: string }) {
return <footer>Visits counted for {process.env[variable] ?? "nobody"}</footer>;
}
"use client";
export default function Stats() {
return <footer>Visits counted for {process.env.NEXT_PUBLIC_ANALYTICS_ID}</footer>;
}
Write process.env.NEXT_PUBLIC_ANALYTICS_ID directly. With process.env[variable] and the name in a prop, the build can't know the name, so nothing is copied in. The server's render had the id, the browser's said nobody, and React reported error #418. The fixed version's JavaScript contains tea-stats-204.
The build stops: 'client-only' cannot be imported from a Server Component module. Where should rememberTea("green") be called instead?
import { rememberTea } from "@/lib/recent";
export default function GreenTeaPage() {
rememberTea("green");
return (
<main>
<h1>Green tea</h1>
</main>
);
}
"use client";
import { useEffect } from "react";
import { rememberTea } from "@/lib/recent";
export default function Remember({ id }: { id: string }) {
useEffect(() => {
rememberTea(id);
}, [id]);
return null;
}
In a useEffect in a Client Component, like Remember, which the page renders with <Remember id="green" />. Effects only run in the browser, so localStorage is there. The recorded build passes, and Chrome logged no error.
Check your understanding
For each question, ask where the code runs, when the value is read, and what reaches the browser.
Question 1 of 7The shop's
.envhasSUPPLIER_KEYandNEXT_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.
Question 2 of 7
.env,.env.local,.env.productionand.env.production.localall setWELCOME. Which value doesnext builduse?Choose an answer to see the explanation.
Question 3 of 7A Client Component imports a module that starts with
import "server-only". What happens?Choose an answer to see the explanation.
Question 4 of 7A page renders
process.env.WELCOMEand nothing else reads the request. The build prints○ /. When isWELCOMEread?Choose an answer to see the explanation.
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.
Question 6 of 7This page passes
supplierto a Client Component that only showssupplier.name. Does the key reach the browser?Read the code, then predictTSXimport 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.
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
.envfiles from the top folder and stops at the first place that has a variable: the real environment, then.env.production.local,.env.local,.env.productionand.envduring a build. NEXT_PUBLIC_values are copied into the code that goes to the browser duringnext build. Writeprocess.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 afterconnection(), insideSuspense, 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.