A Next.js App Router codebase has a `lib/billing.ts` module that reads an API key from `process.env` and calls a paid vendor. What does adding `import 'server-only'` at the top of that file buy you that a comment saying "server only" does not?
answer
- a module has no intrinsic runtime
- refactors move files across the boundary
- the failure should be at build time
- conditional package exports, one throws
- there is a mirror package for the browser
basics
~20 sImporting the server-only package turns a convention into a build failure. If any client module ever imports that file, directly or through a chain of imports, the build breaks and names the offending file instead of silently shipping the module to the browser.
solid answer
~40 sNothing in a module's own text tells you which runtime it lands in — the same `lib/billing.ts` can be imported by a Server Component today and, after a refactor, by a file that starts with `'use client'`. `server-only` is a tiny package whose package exports resolve differently per bundler condition: on the client condition it resolves to a module that throws, so pulling it into a client module graph fails the build with an error naming the importing file. On the server it is an empty module with no runtime cost. It catches the transitive case — a `'use client'` component importing a helper that imports `lib/billing.ts` — which is exactly the case review misses. There is a companion `client-only` package for the reverse direction.
code
typescript · 11 lines// lib/billing.ts
import 'server-only'
export async function getBillingProfile(userId: string) {
const res = await fetch(`https://api.example.com/billing/${userId}`, {
headers: { Authorization: `Bearer ${process.env.BILLING_API_KEY}` },
})
if (!res.ok) throw new Error('billing lookup failed')
return res.json()
}go deeper
Know that server-only is a real package you import for its side effect, and that its job is to make the build fail if server code is ever pulled into the browser bundle.
Explain the mechanism — conditional package exports that resolve to a throwing module under the client condition — and why the transitive import chain, not the individual file, is what makes this necessary.
Show where you place these markers in a real codebase and what they do not cover: values rendered into markup, passed as props, or logged still cross the boundary despite a clean module graph.
Frame it as one control among several: module-graph guards, a rule about what may be passed across the boundary, and secret handling conventions together form the policy, and you should be able to say how each is enforced in CI.
## Why a comment is not enough In the App Router a module has no intrinsic runtime. `lib/billing.ts` is server-side today because everything importing it happens to be server-side. The moment someone adds a `'use client'` file that imports a formatting helper that imports `lib/billing.ts`, the module joins the client graph. Nobody edited `lib/billing.ts`; nobody read the comment at the top of it; the change was three imports away in a different directory. ## What Next already does on its own Next does not blindly inline your whole environment into the client bundle. Only variables prefixed `NEXT_PUBLIC_` are inlined at build time for client code; `process.env.BILLING_API_KEY` is not one of them, so the literal secret is not written into the browser JavaScript. That is genuine protection, and it is why this is a subtler failure than "your key is on the internet". But the rest of the module does ship. The vendor SDK it imports, the request-building code, any hard-coded endpoint or non-env constant, and the shape of your internal calls all end up in the client bundle, inflating it and describing your backend to anyone who reads it. And the code now runs in a place where the key is absent, so requests go out unauthenticated or the function throws at runtime, in production, for one user, instead of at build time on your machine. ## What `server-only` actually is It is a published package with essentially no code. Its `package.json` declares conditional exports: under the browser/client bundling condition it resolves to a module that immediately throws, and under the server condition it resolves to an empty module. So: ```ts import 'server-only' export async function getBillingProfile(userId: string) { const res = await fetch(`https://api.example.com/billing/${userId}`, { headers: { Authorization: `Bearer ${process.env.BILLING_API_KEY}` }, }) return res.json() } ``` If this module ever enters a client entry point's graph, the bundler resolves the client condition, hits the throwing module, and fails the build with a message stating that you are importing a module that is marked server-only, along with the import chain. On the server the import costs nothing at runtime. The mirror image is the `client-only` package: put it at the top of a module that touches `window`, `document` or a browser-only SDK, and importing it from a Server Component fails the build instead of exploding during server rendering. ## Where to put it One import per *module boundary that must not cross*, not one per file. In practice that means the top of your data-access modules, the module that reads secrets, and any wrapper around a privileged SDK. Everything downstream of those is protected transitively, because the bad import chain must pass through the marked file to reach anything below it. ## What it does not do It is a build-time tripwire, nothing more. It does not encrypt anything, and it does not stop the three other ways a secret leaves the server: - **Returning it.** A Server Component that renders `{process.env.BILLING_API_KEY}` into the markup, or passes it as a prop to a client component, has leaked it through a legitimate channel. `server-only` never sees that; the module stayed on the server and handed the value across the boundary itself. - **Logging it** into an error message that surfaces in a client-visible response. - **Naming it `NEXT_PUBLIC_`.** Prefixing a secret makes Next inline it deliberately. No tooling will argue. It also does not make a module *become* server-side. It asserts an intent and enforces it; the enforcement is the whole feature. ## Why interviewers ask it Because it separates candidates who think of the server/client split as a per-file label from those who understand it as a property of the *import graph*. The correct mental model is that `'use client'` marks entry points, everything reachable from an entry point is client code, and `server-only` is how you assert that a given module must never be reachable that way. Once someone states it in those terms, the follow-ups about accidental leaks answer themselves.
- If non-public environment variables are never inlined into the client bundle anyway, what is left to protect?The module around the secret. Its SDK imports, request shapes and internal endpoints all ship and bloat the bundle while documenting your backend. Worse, the code now runs without the key, so it fails at runtime in production rather than at build time. `server-only` converts a silent, late failure into a loud, early one.
- Does importing 'server-only' stop a Server Component from leaking that key to the browser?No. It only prevents the module from being bundled into client code. A Server Component can still render the value into markup or pass it as a prop to a client component, and the RSC payload carries it to the browser. Guarding the module graph and guarding what crosses as data are separate disciplines.
- What is the equivalent guard for a module that must run only in the browser?The `client-only` package. Importing it at the top of a module that touches `window`, `document`, or a browser-only SDK makes the build fail if a Server Component pulls it in, instead of producing a reference error during server rendering that is harder to trace back to the import.
saying these in an interview costs you the question
- Thinks a code comment or naming convention is enforcement
- Believes all of process.env is shipped to the browser
- Assumes the guard also stops secrets passed as props
- Thinks it moves the module to the server rather than asserting it
- Only checks direct imports, ignoring transitive import chains