Directives and server-only enforcement
The use client and use server directives that mark Next.js's server-client boundary, plus the server-only and client-only guards that turn a misplaced import into a build error instead of a browser leak.
Picture this. A teammate opens a one-line pull request: it adds a logging helper to the “Mark as paid” button to see when a click fires. The helper imports a couple of utilities, and one of those, three files away, imports the database client. The tests pass, next build is green, and the PR merges.
The database client is now in the public JavaScript bundle. Its connection logic and the URL it connects to ship to every visitor’s browser, readable in the Network tab by anyone who opens DevTools. Nobody typed anything that looked wrong. The button already had its 'use client' directive, and the directive did its job: it drew the boundary and pulled everything the file imports across it. The leak happened because the boundary worked, not despite it.
That is the gap this lesson closes. A directive is a boundary marker: it tells the framework where the line is. It is not a boundary guard: it never checks whether the code on the client side of that line belongs there.
This lesson covers both directives in full, 'use client' and 'use server', including the rule that makes a directive fail in total silence, and why the framework makes you write a literal string instead of working the boundary out for you. Then it gives you the one-line tools that turn a misplaced server import from a silent production leak into a next build error.
Placing 'use client' correctly
Section titled “Placing 'use client' correctly”You know what 'use client' does: it marks the entry point into the client subgraph, and everything that subgraph imports travels into the browser with it. What trips people in real codebases isn’t what the directive does, but what counts as the directive at all.
'use client' is a directive : a bare string literal at the head of the file, above every import and every statement, on its own line, ending in a semicolon. Single or double quotes both work. Backticks do not. `use client` is a tagged template expression, which the JavaScript engine evaluates as code; the bundler never sees it as a directive.
The directive must come before any import or executable code, but comments may precede it: a license header or a // @ts-nocheck pragma above it is fine. The bundler reads the string as a module-level instruction before processing the body, and comments aren’t part of the body. So the real rule is “before any import or statement,” not “literally the first line” — a distinction that matters the moment a file has a header comment.
The following two tabs show the placement that works and the placement that quietly doesn’t.
'use client';
import { useState } from 'react';
export function MarkPaidButton() { const [isPaid, setIsPaid] = useState(false); // ...}The directive sits above everything. The bundler reads it before the module body and marks this file, and everything it imports, as client.
import { useState } from 'react';
'use client';
export function MarkPaidButton() { const [isPaid, setIsPaid] = useState(false); // ...}An import comes first, so the string is no longer at the head. It’s now an ordinary expression statement, and the bundler ignores it. The file becomes a Server Component, with no error. The useState throws the first time this component renders, and only then.
The way that second case fails generalizes. A directive is just a string: no symbol to resolve, no import to fail, no type to check. As far as TypeScript is concerned, a string sitting in your file is a valid expression. So every way to get the string slightly wrong leaves the file silently classified as a Server Component, with no complaint from any tool:
'use cleint'; // typo: ignored, the file stays a Server Component'use-client'; // hyphen, not a space: ignored`use client`; // backticks make it a tagged template, not a directive: ignored'use client' // with an import above it: no longer at the head, ignoredThe bundler sees a string it doesn’t recognize and treats the file as the default, a Server Component. The file looks like a Client Component to you — it has hooks and an onClick — but it isn’t marked as one, so the first hook or event handler crashes at render time. And because a Server Component runs only when something renders it, that crash can hide in a code path no one exercises until a user clicks the wrong thing in production.
This is why experienced engineers never hand-type the directive. Copy it from a file you know is correct, or let an editor snippet insert it. The string is too important, and too unguarded, to retype from memory.
Once a file has crossed into the client subgraph, every module it imports is already client, so writing 'use client' again at the top of an imported file does nothing — not wrong, just redundant. The boundary is a one-way door: you cross it once, at the entry file, and everything below is on the other side. A stray extra directive deeper in the tree is harmless noise, not a second boundary.
'use server' marks a callable function, not a Server Component
Section titled “'use server' marks a callable function, not a Server Component”The name invites a wrong guess. 'use client' and 'use server' look like a matched pair, so it’s natural to assume one marks Client Components and the other marks Server Components. It doesn’t, and the mistake causes real confusion.
You already know why from the first lesson: Server Components need no directive. They are the default; every file under app/ is a Server Component until a 'use client' boundary above it says otherwise. So 'use server' cannot be what makes a Server Component, because nothing makes one. It does something different.
The two directives are not symmetric:
'use client'marks where code runs. It says “this file and its imports ship to the browser and run there.” It’s about location.'use server'marks a function the browser may call. It says “this stays on the server, but a Client Component can invoke it, and when it does, the call crosses the network and the body runs server-side.” It’s about exposure.
They share three letters and almost no meaning.
The thing 'use server' marks is a Server Action , React’s built-in RPC mechanism. A Client Component calls what looks like an ordinary async function, but the client only holds an opaque reference to it. Invoking that reference fires a network request, the server runs the real function, and the result comes back. It’s the one function-shaped thing allowed to cross the boundary. The full surface — validation, return values, wiring to a form — is the forms chapter; here you need only its two placements.
'use server';
export async function archiveInvoice(id: string) { // mutation logic: the forms chapter}
export async function markInvoicePaid(id: string) { // mutation logic: the forms chapter}At the top of a file, the directive makes every export a Server Action. The convention is to gather them in a file named for the job, here the invoices’ actions. Each is now callable from a Client Component; the bodies are stubs because the real work belongs to a later chapter.
export default async function InvoicesPage() { async function archiveInvoice(id: string) { 'use server'; // mutation logic: the forms chapter }
// ...render the page}For a single action scoped to where it’s used, put 'use server'; as the first line of an async function body instead of at the top of a file. The page has no directive; it’s a Server Component, the default. Only the inner archiveInvoice becomes a Server Action.
The drill below pays off the asymmetry. It gives you behaviors, never the directive strings, and asks which side of the line each belongs on, so you check that you understand each directive’s job rather than which word goes where.
Each of these is a piece of a real app. Sort it by what it needs — to ship to the browser and run there, or to stay on the server and run there when the browser calls it. Drag each item into the bucket it belongs to, then press Check.
onClick that opens a menuuseStatelocalStorage on mountWhy the boundary is explicit, not inferred
Section titled “Why the boundary is explicit, not inferred”The framework could have found the boundary on its own. It could scan each file, spot the ones that call useState or attach an onClick, and silently classify those as client; or it could key off the filename, making anything ending in .client.tsx a Client Component. It deliberately does neither, and instead makes you write the string.
That choice is the clearest example of a principle you’ll meet throughout this stack: prefer explicit over magic. When the framework could either infer something or make you state it, the senior preference is to state it, because a thing you can read beats a thing you have to deduce.
Something you can read, rather than compute, pays off in three places:
- In the source. The first line tells you which side the file runs on, before you’ve parsed a single hook or event handler. Inference would force you to scan the whole file and run the bundler’s classification rules in your head.
- In code review. A diff that adds
'use client'shows a leaf crossing into the bundle right in the changed lines, a visible and discussable event. Under inference, the same change hides as a side effect of adding a hook somewhere. - In git history. The exact commit where a file became a Client Component is a one-line change you can
git blame. Under inference, there’s no moment to point at, just code that quietly started bundling differently.
The same instinct recurs across the stack: explicit dependency arrays on effects, explicit Zod schemas at your system’s edges, explicit return types on Server Actions, each making behavior legible where you read it rather than hidden in a tool’s inference. The directive states the boundary; the rest of this lesson is the tooling that holds you to it.
The leak the directive can’t catch: transitive imports
Section titled “The leak the directive can’t catch: transitive imports”Now we can pin down why the directive missed the leak from the opening.
State the directive’s promise narrowly: 'use client' says “this file and everything it imports are client.” That is all it says. It never adds “and I’ll check that everything it imports should be client.” It marks the boundary; it does not inspect what lands on either side. So when a server-only module (a database client, a helper that reads a secret, a file that uses Node’s fs) gets imported into a client file, directly or several hops down a chain of innocent imports, the propagation rule from the last lesson drags it across. The directive worked as designed. The import was the mistake, and the directive has no opinion about imports.
The dangerous word is transitive. A transitive import is one you never wrote down: your file imports a helper, the helper imports another, and four files later something pulls in the database client. To the bundler, all of it counts as imported by your file. That is why these leaks hide. The damage isn’t in the import you can see; it’s at the bottom of a chain you’d have to walk by hand.
When it happens, you get one of two outcomes, both bad:
- Loud but cryptic. The dragged-in module reaches for a Node-only API like
fsornetthat doesn’t exist in a browser. The build crashes, but the error points at the missing browser global deep in some dependency, nowhere near the import you got wrong. - Silent, which is worse. The module is pure JavaScript, so it bundles fine: no missing global, no crash. Now the code that reads your secret, sometimes with the secret value inlined right into it, ships in the public bundle. The build is green, the deploy succeeds, the leak is silent, exactly like the one that opened this lesson.
You’ve seen this asymmetry before: silent failures slip past the framework while loud ones don’t. This is one more silent failure, except this time there’s a tool that turns it loud. Before reaching for it, see how a real leak hides, because it’s subtler than importing the database straight into a button.
'use client';import { formatInvoice } from './format-invoice';// ...renders a button, uses formatInvoice for the label
// app/invoices/_components/format-invoice.tsximport { calculateTotal } from '@/lib/pricing';// ...turns an invoice into a display string
// lib/pricing.tsimport { db } from '@/db';// ...reads tax tables from the database to total an invoice
// db/index.ts// the database client: connection URL, pool, credentialsexport const db = /* ... */;The button is a Client Component and imports formatInvoice to build its label. Nothing dangerous yet.
'use client';import { formatInvoice } from './format-invoice';// ...renders a button, uses formatInvoice for the label
// app/invoices/_components/format-invoice.tsximport { calculateTotal } from '@/lib/pricing';// ...turns an invoice into a display string
// lib/pricing.tsimport { db } from '@/db';// ...reads tax tables from the database to total an invoice
// db/index.ts// the database client: connection URL, pool, credentialsexport const db = /* ... */;formatInvoice has no directive of its own, but it’s imported from a client file, so it’s already client. It imports a pricing helper to compute totals.
'use client';import { formatInvoice } from './format-invoice';// ...renders a button, uses formatInvoice for the label
// app/invoices/_components/format-invoice.tsximport { calculateTotal } from '@/lib/pricing';// ...turns an invoice into a display string
// lib/pricing.tsimport { db } from '@/db';// ...reads tax tables from the database to total an invoice
// db/index.ts// the database client: connection URL, pool, credentialsexport const db = /* ... */;The pricing helper needs tax tables, so it imports the database client. This is the hop that does the damage, two files from anything a reviewer was looking at.
'use client';import { formatInvoice } from './format-invoice';// ...renders a button, uses formatInvoice for the label
// app/invoices/_components/format-invoice.tsximport { calculateTotal } from '@/lib/pricing';// ...turns an invoice into a display string
// lib/pricing.tsimport { db } from '@/db';// ...reads tax tables from the database to total an invoice
// db/index.ts// the database client: connection URL, pool, credentialsexport const db = /* ... */;The database client, with its connection URL, pool, and credentials, is now part of the client bundle. next build said nothing. This is the leak, three innocent imports deep.
No single file is wrong: a button needs a label, a label needs a total, a total needs tax tables. The mistake lives only in the chain, and following chains is work humans are bad at and machines are good at. The next section hands the job to a machine.
server-only and client-only: failing the build when server code reaches the client
Section titled “server-only and client-only: failing the build when server code reaches the client”The fix is one line at the top of the file that must never reach the browser. For the database client, that’s db/index.ts:
import 'server-only';
import { drizzle } from 'drizzle-orm/node-postgres';
import { env } from '@/env';
export const db = drizzle(env.DATABASE_URL);That import 'server-only'; is a side-effecting import : you import it for what its presence does, not for any value. If the module importing it ever lands in the client bundle, the build fails. So the moment a Client Component imports db/index.ts, directly or three transitive hops away like the chain you just saw, next build stops and names the offending import path. The silent leak becomes a loud failure before deploy, pointing at the exact chain.
client-only is the mirror: put import 'client-only'; at the top of a module that must never run on the server, such as a helper bound to a browser API or a library that reads window the instant it’s imported. Same mechanism, opposite direction. If such a module is pulled into a Server Component, the build fails instead of crashing at render with a confusing window is not defined. You’ll reach for it far less often than server-only, since most code runs safely on a server, but for a stubbornly browser-only library it earns its line.
One precise point, because the detail shifted recently and stale advice is everywhere: Next.js recognizes both server-only and client-only internally and ships its own type declarations, so installing the npm packages is optional. The import line works whether or not the package sits in node_modules. You may still install them as devDependencies to keep the linter from flagging the import as unresolved, but the line is the contract; the protection comes from the framework reading it.
Adopt this as a habit and keep it for the rest of the course: every module that must stay on the server opens with import 'server-only';. Your database client, auth helpers, email sender, billing adapter, and every _actions.ts file all start with that line, and the course’s conventions already bake it into every SDK adapter under lib/. The cost is one line per file. The payoff is that a careless import six months from now becomes a build error a reviewer sees, not an incident a customer reports.
import 'server-only';
// the Better Auth server instance
export const auth = /* ... */;Now the second confusion, the one that catches people who have learned both 'use server' and server-only. The words overlap, but the two are opposites:
import 'server-only';says “this file errors if it reaches the client.” It’s a prohibition: the contents are off-limits to the browser.'use server';says “every export here is a Server Action the client can call.” It’s an exposure: the contents are deliberately reachable from the browser as callable endpoints.
One forbids the client from getting near the module; the other hands the client specific functions to invoke. A file would essentially never want both. The two tabs below put them side by side:
import 'server-only';
// session + auth logic: must never leave the server
export const auth = /* ... */;The session and credential logic must never reach the browser, so the file guards itself. If anything client-side imports it, the build fails. Nothing here is callable from the client.
'use server';
export async function archiveInvoice(id: string) { // mutation logic: the forms chapter}The opposite intent. A button in a Client Component calls archiveInvoice to archive an invoice, so the directive exposes it as an endpoint the browser can invoke. One file closes a door, the other opens a window.
You now have all four strings on the table. They look alike and are easy to confuse, so here is one scannable contrast that separates them by job.
| String | Kind | What it marks | Direction | Failure | Canonical file |
|---|---|---|---|---|---|
'use client' | directive | A component that runs in the browser | Pulls the file and its imports toward the client | Typo → silently treated as a Server Component; crashes at render | _components/mark-paid-button.tsx |
'use server' | directive | A function the client can call (a Server Action) | Exposes a server function to the browser | Forgotten on a called action → it isn't callable | app/invoices/_actions.ts |
import 'server-only' | guard | A module that must never reach the browser | Blocks the file from crossing to the client | Omitted → a server module can leak into the client bundle | db/index.ts, lib/auth.ts |
import 'client-only' | guard | A module that must never run on the server | Blocks the file from crossing to the server | Omitted → crashes server-side with window is not defined | a window-reading browser helper |
The exercise below is the same decision in miniature: given a file, what line belongs at the top? One of the right answers is no line at all, since Server Components are the default.
Match each file to the line that belongs at the top of it. One of them takes no line at all. Click an item on the left, then its match on the right. Press Check when done.
db/index.ts — the database clientimport 'server-only';app/invoices/_actions.ts — functions a button calls to mutate invoices'use server';app/invoices/_components/mark-paid-button.tsx — a button with onClick and useState'use client';lib/use-media-query.ts — a helper that reads window the moment it loadsimport 'client-only';app/invoices/page.tsx — a plain page that fetches and renders invoicesRecap and what’s next
Section titled “Recap and what’s next”Three things to carry forward.
- The two directives are literal strings with strict placement, and they do opposite jobs.
'use client'marks where a component runs: it sits above all imports, propagates to everything the file imports, and fails silently on a typo.'use server'marks a Server Action, a function the client can call that runs on the server; it has nothing to do with Server Components, which need no directive. - Explicit over magic. The framework makes you write the boundary as a string you can read, review, and
git blame, instead of inferring it behind your back. server-onlyandclient-onlyare the enforcement. One side-effecting import turns a silent server-code leak into a loudnext builderror that names the offending chain. Open every server-only module withimport 'server-only';.
Two threads stay open. The next lesson covers exactly what may cross the wire when you pass props to a Client Component: the serialization rules behind “props must be serializable,” and the secrets-in-props leak in full. Hydration’s failure modes follow. The full Server Action surface belongs to a later chapter on forms.
External resources
Section titled “External resources”The placement rules in full, including the comments-are-OK, backticks-are-not detail.
The directive that marks Server Actions, and where it's allowed to sit.
The server-only / client-only section, and the current take on installing them.
Sebastian Markbåge ties server-only, 'use server', and the secrets-in-props leak into one security model.