Skip to content
Chapter 30Lesson 3

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.

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.

app/invoices/_components/mark-paid-button.tsx
'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.

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:

directive look-alikes — all silently ignored
'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, ignored

The 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.

app/invoices/_actions.ts
'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.

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.

Ships to the browser Runs in the browser — needs 'use client'
Stays on the server Runs server-side when called — a 'use server' action
A button with an onClick that opens a menu
A date picker holding its own open/closed state
A search field that filters as you type with useState
A component that reads localStorage on mount
A function that writes a new invoice to the database when a form submits
The thing the client calls to archive an invoice
A function that charges a card and emails a receipt when “Pay” is clicked
An export a Client Component invokes to mark an invoice paid

Why 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.

mark-paid-button.tsx ? client or server
boundary inferred — not written down
Which side is this on? You'd have to read the whole file and run the rules in your head.

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 fs or net that 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.

app/invoices/_components/mark-paid-button.tsx
'use client';
import { formatInvoice } from './format-invoice';
// ...renders a button, uses formatInvoice for the label
// app/invoices/_components/format-invoice.tsx
import { calculateTotal } from '@/lib/pricing';
// ...turns an invoice into a display string
// lib/pricing.ts
import { db } from '@/db';
// ...reads tax tables from the database to total an invoice
// db/index.ts
// the database client: connection URL, pool, credentials
export const db = /* ... */;

The button is a Client Component and imports formatInvoice to build its label. Nothing dangerous yet.

app/invoices/_components/mark-paid-button.tsx
'use client';
import { formatInvoice } from './format-invoice';
// ...renders a button, uses formatInvoice for the label
// app/invoices/_components/format-invoice.tsx
import { calculateTotal } from '@/lib/pricing';
// ...turns an invoice into a display string
// lib/pricing.ts
import { db } from '@/db';
// ...reads tax tables from the database to total an invoice
// db/index.ts
// the database client: connection URL, pool, credentials
export 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.

app/invoices/_components/mark-paid-button.tsx
'use client';
import { formatInvoice } from './format-invoice';
// ...renders a button, uses formatInvoice for the label
// app/invoices/_components/format-invoice.tsx
import { calculateTotal } from '@/lib/pricing';
// ...turns an invoice into a display string
// lib/pricing.ts
import { db } from '@/db';
// ...reads tax tables from the database to total an invoice
// db/index.ts
// the database client: connection URL, pool, credentials
export 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.

app/invoices/_components/mark-paid-button.tsx
'use client';
import { formatInvoice } from './format-invoice';
// ...renders a button, uses formatInvoice for the label
// app/invoices/_components/format-invoice.tsx
import { calculateTotal } from '@/lib/pricing';
// ...turns an invoice into a display string
// lib/pricing.ts
import { db } from '@/db';
// ...reads tax tables from the database to total an invoice
// db/index.ts
// the database client: connection URL, pool, credentials
export 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.

1 / 1

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:

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.

lib/auth.ts
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:

lib/auth.ts
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.

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 four boundary strings, by job. Two are directives (where code runs / what the client can call); two are guards (what a module is forbidden to reach).

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 client
import '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 loads
import 'client-only';
app/invoices/page.tsx — a plain page that fetches and renders invoices
nothing — a Server Component is the default

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-only and client-only are the enforcement. One side-effecting import turns a silent server-code leak into a loud next build error that names the offending chain. Open every server-only module with import '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.