Skip to content
Chapter 30Lesson 3

Directives and server-only enforcement

The use client and use server directives that mark the App Router's server-client boundary, and the server-only and client-only guards that make a misplaced import fail at build time instead of leaking to the browser.

A teammate opens a pull request. It’s one line: they add a logging helper to the “Mark as paid” button so they can see when a click fires. The helper imports a couple of utilities, and one of those, three files away where nobody was looking, imports the database client. The tests pass. next build is green. 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 exactly 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’s the gap this lesson closes. You’ve been typing 'use client' since the last lesson on the strength of one fact: it marks a file, and the mark flows down to everything the file imports. That fact is true, and it is not enough. A directive is a boundary marker, so it tells the framework where the line is. It is not a boundary guard, so it never checks whether the things on the client side of that line have any business being there.

By the end of this lesson you’ll be able to do three things. First, read and place both directives, 'use client' and its sibling 'use server', with their exact rules, including the one that makes a directive fail in total silence. Second, explain why the framework makes you write a literal string instead of working the boundary out for you. Third, make a misplaced server import fail loudly at next build instead of slipping into production the way the one above did. The last lesson promised to cover that directive in full. Here are both directives, plus the one-line tools that turn the gap into a build error.

You already own the headline fact about 'use client': it marks the entry point into the client subgraph, and everything that subgraph imports travels into the browser with it. That part is settled. What the last lesson deferred, and what trips people in real codebases, isn’t about what the directive does. It’s about what counts as the directive at all.

Here’s the rule. 'use client' is a directive : a bare string literal that sits at the head of the file, above every import and every statement. It is not a function call, not an import, and not a special comment. It is a string, on its own line, ending in a semicolon. Single or double quotes both work. Backticks do not. `use client` is a tagged template expression, a completely different thing that the JavaScript engine evaluates, not a directive the bundler recognizes.

One allowance is worth stating precisely, because the rule is easy to remember too strictly as “line one, no exceptions.” The directive must come before any import or executable code, but comments may precede it. A license header or a // @ts-nocheck pragma above the directive is fine. The reason is mechanical: the bundler reads this string as a module-level instruction before it processes the module’s body, and comments aren’t part of the body, so they don’t get in the way. The real rule is “before any import or statement.” Thinking of it as “literally the first line” is close enough until a file has a header comment, and then it misleads you.

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. This is the only placement that does what you think it does.

The second tab is worth dwelling on, because the way it fails generalizes. A directive is just a string. There is no symbol to resolve, no import to fail, no type to check. TypeScript has nothing to say about it, because as far as the type system is concerned a string sitting in your file is a perfectly valid expression. So every way you can 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

None of those error. The bundler sees a string it doesn’t recognize as a directive and treats the file as the default, a Server Component. The file looks like a Client Component to you, since it has hooks and an onClick, but it isn’t marked as one. The first useState or event handler it hits will crash at render time. And because a Server Component only runs when something renders it, that crash can hide in a code path you don’t exercise until a user clicks the wrong thing in production.

This is why experienced engineers follow a small habit here: never hand-type the directive. Copy it from a file you know is correct, or let your editor’s snippet insert it. The string is too important, and too unguarded, to retype from memory and hope you got it right.

One last rule, and it’s a reassuring one. Once a 'use client' file has crossed into the client subgraph, every module it imports is already client, so writing 'use client' again at the top of one of those imported files does nothing. It isn’t wrong, just redundant. The boundary is a one-way door: you cross it once, at the top of the entry file, and everything below is on the other side. You never need to declare the client side again deeper in the tree, and a stray extra directive down there is harmless noise rather than a second boundary.

'use server' and the asymmetry with 'use client'

Section titled “'use server' and the asymmetry with 'use client'”

There’s a second directive, 'use server', and the moment you see the name it’s easy to fill in the wrong meaning. The symmetry between 'use client' and 'use server' is right there in the words, so the obvious guess is that one marks Client Components and the other marks Server Components. That guess is wrong, and it causes real confusion, so let’s clear it up before looking at a single line of code.

Start from a fact you already have from the first lesson: Server Components need no directive at all. 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 the thing that makes a Server Component, because there is no such thing to make: Server Components are what you get for free by writing nothing. A file with 'use server' at the top is not extra-server or more of a Server Component. It is something else entirely.

Here’s the actual shape of the two directives, side by side:

  • 'use client' marks where a component 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 is allowed to call. It says “this function stays on the server, but a Client Component may invoke it, and when it does, the call crosses the network and the body runs server-side.” It’s about exposure.

One is a statement about where code lives. The other is a statement about what the client is permitted to reach across the wire and trigger. They share three letters of syntax and almost nothing of meaning.

The thing 'use server' marks has a name: a Server Action . It’s the framework’s built-in RPC mechanism: a Client Component calls what looks like an ordinary async function, but the body runs on the server. Under the hood the client only ever holds an opaque reference to that function. Invoking the 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 you’ve been drawing. What else is allowed to cross is the next lesson’s subject, and the full Server Action surface (validation, error handling, what it returns, how it wires to a form) is a whole later chapter. Here you only need 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
}

The directive at the top of the file 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, and the bodies are stubs because the real work belongs to a later chapter.

To recap the two placements: file-level 'use server'; turns every export in the file into a Server Action, and the convention is to collect them in an _actions.ts file. Inline 'use server'; as the first line of an async function body turns that single function into a Server Action, scoped to where it’s defined. Both bodies above are stubs, because everything inside a Server Action, the part that does the work, belongs to the forms chapter, so leave them empty for now.

The drill below pays off the asymmetry. It gives you behaviors and features, never the literal directive strings, and asks which side of the line each belongs on. The point is to check that you’ve understood the job of each directive, not just memorized 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 a literal string and not framework magic

Section titled “Why a literal string and not framework magic”

Step back from the mechanics for a second, because there’s a design decision worth seeing clearly. The framework could have figured the boundary out on its own. It could scan every file, notice the ones that call useState or attach an onClick, and silently classify those as client. Or it could key off the filename, so that anything ending in .client.tsx becomes a Client Component with no string required. Both are technically possible. The framework deliberately does neither. Instead it makes you write a literal string at the top of the file.

That choice is the clearest example of an idea you’ll meet over and over in this stack: prefer explicit over magic. When the framework could either infer something for you or make you state it outright, the senior preference is to state it outright, because a thing you can read beats a thing you have to deduce.

Picture the difference concretely. Imagine the inference version were real, and you open an unfamiliar file. To answer “does this run on the server or in the browser?” you’d have to scan the whole file for any hook, any event handler, any browser API, and then run the bundler’s classification rules over what you found, in your head. With the directive, that question is answered by the first line you read, before you’ve parsed a single piece of logic. The explicit version turns the boundary from something you compute into something you see.

Something you can see, rather than compute, pays off in three places that matter:

  • In the source. The first line of every file tells you which side it’s on, every time you open it, with nothing to deduce.
  • In code review. When a diff adds 'use client' to a file, the reviewer sees a leaf cross into the bundle right there in the changed lines, a visible and discussable event. With inference, the same change would be a silent side effect of adding a hook somewhere, invisible in review.
  • In git history. The exact commit where a file became a Client Component is a one-line change you can git blame. The moment is recorded. With inference, there’s no moment to point at, just code that started bundling differently with no clear record of when or why.

This isn’t a one-off rule for directives. It’s a stance that runs through the whole course, and you’ll see the same instinct each time the framework offers to guess on your behalf. You’ll write explicit dependency arrays on effects rather than letting the framework guess what an effect depends on. You’ll declare explicit Zod schemas at the edges of your system rather than trusting whatever shape the data happens to arrive in. You’ll annotate what a Server Action returns rather than leaning on inference at a boundary that crosses the network. The principle is the same every time: at the points that matter, make the behavior legible where you read it, not hidden in a tool’s inference.

The directive is this principle’s statement: the string you write down. The rest of this lesson is its enforcement: the tools that make the explicit boundary one the framework will actually hold you to.

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.

When convention isn’t enough: the leak the directive can’t stop

Section titled “When convention isn’t enough: the leak the directive can’t stop”

Now back to the leak from the opening, because we can finally pin down why the directive didn’t catch it.

Here is what the directive does, stated as narrowly as it’s true: 'use client' says “this file and everything it imports are client.” That’s the whole promise. Notice what it does not say. It does not add “and I’ll check that everything it imports should be client.” It marks the boundary; it does not inspect what ends up on either side of it. 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, whether directly or several hops down a chain of innocent-looking imports, the same propagation rule from the last lesson drags it across the boundary. The directive worked precisely as designed. The import was the mistake, and the directive has no opinion about imports.

The dangerous word there is transitive. A transitive import is one you never wrote down yourself: your file imports a helper, the helper imports another, and four files later something pulls in the database client. As far as the bundler is concerned, all of it counts as imported by your file. That’s 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 follow by hand.

When that happens, you get one of two outcomes, and they’re both bad:

  • The loud but cryptic case. The dragged-in module reaches for a Node-only API like fs or net that simply doesn’t exist in a browser. The build might crash, but the error points at the missing browser global, deep in some dependency, nowhere near the import you actually got wrong. You’ll spend an afternoon debugging a symptom three files away from the cause.
  • The silent case, which is worse. The module is pure JavaScript, so it bundles fine. No missing global, no crash. And now the code that reads your secret, possibly with the literal secret value inlined right into it, ships in the public bundle. The build is green. The deploy succeeds. The leak is completely silent, exactly like the one that opened this lesson.

You’ve seen this shape before. The first lesson drew the asymmetry: importing a Server Component into a Client Component fails loudly at build time, but passing a secret as a prop leaks silently. The last lesson sharpened it: the framework guards the loud failures, not the silent ones. This leak is that same asymmetry once more, a silent failure the framework won’t catch for you. The difference is that this time there’s a tool that turns it into a loud one. Before reaching for the tool, though, it helps to see how a real leak hides, because it’s subtler than someone 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 = /* ... */;

It starts innocently. The button is a Client Component and imports formatInvoice to build its label. Nothing here looks dangerous.

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. Still nothing alarming.

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, and it’s two files away 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

Three imports, and every one looks reasonable on its own line: a button needs a label, a label needs a total, a total needs tax tables. No individual file is obviously wrong, which is exactly why no human reviewer flags it and why the build sails through. The mistake exists only in the chain, and following chains is just the kind of work humans are bad at and machines are good at. The next section hands the job to a machine.

server-only and client-only: turning a leak into a build error

Section titled “server-only and client-only: turning a leak into a build error”

The fix is one line, and it goes 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'; line is a side-effecting import : you’re not pulling a value out of it, you’re importing it purely for what its presence does. And what it does is simple. server-only is set up so that if the module importing it ever ends up in the client bundle, the build fails. So the moment a Client Component imports db/index.ts, whether directly or three transitive hops away like the chain you just saw, next build stops and prints an error naming the offending import path. The silent leak becomes a loud failure before deploy, one that points at the exact chain. The afternoon of debugging and the secret in production are both gone, traded for one line at the top of the file.

client-only is the mirror image. import 'client-only'; goes at the top of a module that must never run on the server, such as a helper bound to a browser API, or a third-party library that reads window the instant it’s imported. Same mechanism, opposite direction: if such a module gets pulled into a Server Component, the build fails instead of crashing at render time with a confusing window is not defined. You’ll reach for client-only far less often than server-only, because most code is perfectly safe to run on a server. But when you’re wrapping a stubbornly browser-only library, it earns its line by failing fast and pointing at the cause.

One precise note on how these actually work, because the detail shifted recently and stale advice is everywhere. Next.js recognizes both server-only and client-only internally: the build error comes from the framework, not from the package, and Next.js ships its own type declarations for both. As of current Next.js, installing the npm packages is optional. The import 'server-only'; line does its job whether or not the package is in your node_modules. You may still run npm install server-only client-only (usually as devDependencies) so your linter doesn’t flag the import as unresolved, but don’t think of the install as the thing protecting you. The import line is the contract, and the protection comes from the framework reading it.

This is where the principle from the last section becomes concrete. The directive made the boundary explicit; server-only makes it enforced. That’s a habit worth adopting now and keeping for the rest of the course: every module that must stay on the server opens with import 'server-only';. Your database client, your auth helpers, your email sender, your billing adapter, and every _actions.ts file all start with that line. The course’s code conventions already bake this in, so every SDK adapter under lib/ begins with it. The cost is one line per file. The payoff is that the bundle cannot silently ship server code: a careless import six months from now becomes a build error a reviewer sees, rather than an incident a customer reports.

lib/auth.ts
import 'server-only';
// the Better Auth server instance
export const auth = /* ... */;

Now the second confusion to clear up, the one that catches people who have learned both 'use server' and server-only. Because the words overlap, it’s easy to blend them, yet they 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, full stop.
  • '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, because the two intentions contradict each other. The two tabs below put them side by side so the contrast lands.

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; it’s sealed off.

You now have all four strings on the table: the two directives and the two guards. They look alike and are easy to confuse, so it helps to have one scannable contrast that separates them by job. Here it is.

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 you’ll make in real code, in miniature: given a file, what’s the right line at the top of it? Notice that one of the right answers is no line at all, because Server Components are the default, and reaching to mark every file is its own kind of mistake.

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 directives are literal strings with strict placement, and they do different jobs. 'use client' marks a component that runs in the browser; it must sit above all imports, it propagates to everything the file imports, and a typo in it fails silently. 'use server' marks a Server Action, a function the client can call that executes on the server, and has nothing to do with Server Components, which need no directive at all. The names are parallel but the jobs are opposite, so don’t blend them.
  • Explicit over magic. The framework makes you write the boundary as a string you can read, review, and git blame rather than inferring it behind your back. That’s a senior stance you’ll meet again across the stack: explicit effect dependencies, explicit Zod schemas at the edges, explicit return types on actions.
  • 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. Adopt the habit now: every module that must stay on the server opens with import 'server-only';.

Two threads stay open for the rest of this chapter. The next lesson covers exactly what’s allowed to 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 shown in full. After that comes hydration’s failure modes, what happens when the server render and the browser render disagree. The full Server Action surface you kept stubbing out (validation, errors, return shapes, form wiring) belongs to a later chapter on forms and Server Actions. Today you only needed the directive that marks one.