Skip to content
Chapter 43Lesson 1

The "use server" seam

Meet Server Actions, the Next.js way to run a database mutation from a browser form by calling a server function directly instead of hand-building an API route.

You have a <form> inside a Client Component, and on submit it needs to write a row to your database. The form runs in the browser; the database lives on a server the browser can never touch directly. Something has to carry the submitted values across that gap, run the insert where the credentials live, and hand a result back.

One way to build that bridge is by hand: add an app/api/invoices/route.ts, write a POST handler, fetch it from the form’s submit handler, set the headers, serialize the body to JSON on the way out, parse it on the way in, and serialize the response on the way back. That is a lot of plumbing for a single form, and all of it is now yours to own and maintain.

The platform default collapses all of it into one function:

app/invoices/actions.ts
'use server';
export async function createInvoice(formData: FormData) {
// write the row, return a result
}

You import createInvoice into your Client Component and call it, and that is the whole bridge: no route file, no fetch, no hand-rolled serializer. This is a Server Action , and it is how a 2026 SaaS app handles the overwhelming majority of its mutations.

That convenience is also where the risk lives, and learning to see it is the point of this lesson. await createInvoice(formData) reads like a normal function call, as if the function runs inside your code. It does not. Every one of those calls is an HTTP POST from an untrusted browser to your server, with the same trust boundary as any public endpoint on the open internet. The skill is to hold both readings at once: the convenience that makes the call feel local, and the network boundary that demands you distrust everything coming across it.

You already have the pieces this builds on. You met the 'use client' and 'use server' directives and what they mean for where code runs, in Directives and server-only enforcement. You met the wire that React data travels on, in What crosses the RSC wire. This lesson applies both of those to the mutation side. By the end you’ll be able to answer three questions: what the syntax is, what’s allowed to cross the wire, and what boundary discipline you write from your very first action. You’ll also leave holding an empty createInvoice skeleton that the rest of this chapter fills in, one seam at a time.

What actually happens when you call a Server Action

Section titled “What actually happens when you call a Server Action”

Start with the mental model, because everything else follows from it. The convenience is real, but it sits on top of a round-trip, and you need to see that round-trip underneath.

When you mark an async function 'use server', two things happen to it at once. On the server, it becomes an endpoint: code that only ever runs server-side, with access to your database, your secrets, your session. On the client, the framework needs something the browser can hold and call. It cannot ship the function body to the browser, because the body reads your database and has no business in a bundle anyone can open in DevTools. So the compiler ships a stand-in instead.

That stand-in is an opaque ID , a stable hashed string rather than your source code. When you import { createInvoice } in a Client Component, what gets bound to your variable is that ID. The body never shipped.

So here is what await createInvoice(formData) really does, step by step. The client takes your arguments and serializes them into the RSC payload , the same wire format from the RSC chapter. It opens an HTTP POST to the server carrying two things: the action’s opaque ID and the serialized arguments. The server receives the POST, looks up the ID to find the real createInvoice function, and runs it with the deserialized arguments. The function returns a value, the server serializes that and sends it back, the client deserializes it, and the await you wrote resolves.

That is a full network round-trip: request, processing, response. The framework performs it transparently every time. From inside your component, nothing about the call site hints at the network. It looks like a local function call from the first character to the last.

This is where one of the course’s core principles applies. Prefer explicit over magic. A different framework could have hidden this entirely: auto-generate a client, auto-generate an endpoint, never make you write anything. React instead makes you write 'use server'. That directive is not decoration. It is the seam made visible, a single token that marks where your code stops being local and starts being a network boundary. The platform deliberately refuses to hide the boundary from you, because the boundary is the thing you most need to keep in view. When you read 'use server', you should hear “public POST endpoint” every time.

Scrub through the round-trip below. The left lane is the browser, the right lane is your server. Watch the one fact that survives every step: the browser only ever holds the opaque ID, never the function itself.

Browser
holds createInvoice opaque id
imported, ready to call
Server
idle
The Client Component holds createInvoice — but the binding is an opaque action id, not the function body. The body never shipped to the browser.
Browser
holds createInvoice opaque id
serialize(formData)
Server
idle
The form submits. The client serializes the arguments — here, the FormData — into the RSC payload.
Browser
holds createInvoice opaque id
POST sent
POST · id + args
Server
receiving
An HTTP POST crosses the network carrying the action id and the serialized arguments. This is a public request — same trust boundary as any endpoint on the internet.
Browser
holds createInvoice opaque id
awaiting
running
Server
id → createInvoice(args)
The server resolves the id back to the real function and runs it. This is where parse, authorize, and the database write will live — the five seams you fill in.
Browser
holds createInvoice opaque id
awaiting
result
Server
serialize(result)
The function returns. The server serializes the return value back across the wire.
Browser
holds createInvoice opaque id
await resolved
Server
done
The client deserializes the result and the await resolves. From the component’s point of view it looked local the whole time — that illusion is the thing to stay suspicious of.

One detail before we move on, because it’s the flip side of the same model. If you call a 'use server' function from a Server Component, which is code that already runs on the server, there is no POST, no serialization, no opaque ID. It’s just a function calling another function in the same process. The directive only matters at the server/client boundary. The round-trip you just scrubbed through is what happens specifically when a client calls the action, and that’s the case that matters, because that’s where the network and the trust boundary are real.

Two ways to declare one: file-level and inline

Section titled “Two ways to declare one: file-level and inline”

There are two places you can write 'use server', and the rule is simple: file-level by default. Reach for the other one only under a specific condition you’ll rarely meet.

A file-level directive is 'use server' as the very first line of a module. It turns every exported async function in that file into a Server Action. This is the course default. Actions live in their own file, either an actions.ts next to the route that uses them or a feature file, with the directive at the top, co-located with the feature and easy to grep for. When you want to know what the client can invoke on the server, you open the actions.ts files. The boundary has an address.

The inline form is 'use server' as the first statement inside an async function defined within a Server Component. You’d reach for it in exactly one situation: the action needs to close over a value that exists only during that server render, such as a request-scoped ID or a piece of derived auth state, that the Client Component must never see. It’s a genuine carve-out, and it’s rare in 2026 SaaS code. Know it exists, and reach for the file-level form almost every time.

The following two tabs show the same idea, declaring an action, in its two shapes. The first is the default, the second is the carve-out.

app/invoices/actions.ts
'use server';
export async function createInvoice(formData: FormData) {
// ...
}

The default: reach here first. One directive at the top of the file promotes every exported async function in it to a Server Action. The action lives in a known place, co-located with its feature and searchable. This is the shape you’ll write for the rest of the chapter.

Once an action exists, a Client Component imports it like any other function. There are three shapes for actually invoking it, and you’ll meet all three across this unit. We’re naming them here, not wiring them up; the full mechanics arrive in the next chapter on forms. For now, hold one idea: all three call the same function, and the framework does the POST in all three. What differs is who owns the pending state and how the call gets triggered.

The three call sites
// 1. As a form's action prop — posts the FormData directly
<form action={createInvoice}>
// 2. Through useActionState — the hook owns pending state and the latest result
const [state, formAction, pending] = useActionState(
(prev, formData) => createInvoice(formData),
initialState,
);
// 3. Imperatively, inside an event handler — for actions outside a form submit
await archiveInvoice(invoice.id);

The first shape, <form action={createInvoice}>, is the everyday form pattern: the browser hands the form’s FormData straight to the action. The second, useActionState, is a React 19 hook that wraps the action and gives you back the pending flag and the latest result; it’s the form layer’s workhorse. The third is the imperative call inside an event handler, for when there’s no form to submit: a delete button, a toggle, an “archive this” action that fires on click.

One signature detail is worth naming now so it doesn’t catch you later. When you hand an action to useActionState, the hook injects the previous state as that action’s first parameter, with the form data second, so the action you pass it is shaped async function action(prevState, formData), not async function action(formData). Keep two different “first arguments” straight: the hook’s first argument is the action itself (useActionState(action, initial)), while the action’s own first parameter becomes the previous state. You don’t need to internalize this yet, since the next chapter wires it up properly. Just know that the form-data-only signature and the useActionState signature are not the same shape.

Here’s the detail that catches people, and it’s worth slowing down for. Because a Server Action call is really an HTTP POST, the arguments don’t get passed to the function the way local arguments do. They get serialized, shipped across the network as the RSC payload, and deserialized on the other side. So the question “what can I pass to an action?” is really “what can survive that serialization?”, and the answer is narrower than “anything I can hold in a variable.”

The wire is the structured-clone-plus-React-extensions superset you met in the RSC chapter, built on the browser’s structured clone algorithm with React’s additions on top. If a value serializes through it, it can be an argument. If it doesn’t, the call fails.

What’s accepted: primitives (including BigInt, undefined, and null), plain objects, arrays, Map, Set, Date, typed arrays and ArrayBuffer, FormData, File, Promise, and references to other Server Actions.

What’s rejected: functions and closures, class instances, anything carrying a custom prototype, WeakMap and WeakSet, DOM nodes, and the event object from an event handler. Two specifics are worth pinning down. Temporal values, including Temporal.PlainDate and the rest, do not cross; the course convention is to encode them as ISO strings at the boundary and parse them back inside the action. And JSX is not a valid action argument: React elements travel server-to-client on the render wire, but they cannot ride into an action’s parameters. The render wire and the action-argument wire allow overlapping but not identical sets, so don’t assume that because a value crossed one wire it crosses the other.

This leaves you with two clean defaults. For forms, take FormData as the only argument and parse it on entry; you’ll build that parse step in the next lesson. For imperative calls, take a plain object or a primitive ID, never a class instance and never a Drizzle row.

That last case is the trap, and it’s the single most common serialization bug at this seam, so give it room. A row you get back from Drizzle looks like a plain object in the debugger: it has your columns as keys, and the values read fine. But it isn’t plain. It carries a custom prototype, and the moment you pass it to a Server Action, serialization throws. The instinct to “just pass the invoice I already loaded” to an archiveInvoice(invoice) action fails on exactly this. The fix is almost always to pass the plain ID and let the action re-read what it needs: archiveInvoice(invoice.id). For the rare case where you genuinely must ship a whole row’s data, JSON.parse(JSON.stringify(row)) strips the prototype down to a plain object, but treat that as an escape hatch, not a default.

Try sorting these. Some are obvious, and a couple are the exact decoys that trip people in real code.

A Client Component is calling a Server Action and needs to pass one of these as an argument. Sort each by whether it can cross the wire. Drag each item into the bucket it belongs to, then press Check.

Crosses the wire Serializes through the RSC payload
Rejected Won't serialize — the call fails
FormData
a string invoice id
{ id, total } plain object
new Date()
a Map
a File
() => archiveInvoice(id)
a Drizzle invoice row
new InvoiceModel()
Temporal.PlainDate.from('2026-01-01')

Because the client only ever holds the opaque ID, the action’s source code does not ship to the browser at all. This has a useful consequence: importing an action into a Client Component costs that component nothing in bundle size. You can keep your action in a feature file packed with database queries and server imports, import it into a tiny client form, and none of that server code follows it into the bundle. The import resolves to a reference, not the body. On top of that, unused action exports get dead-code-eliminated at build time.

There’s a security model underneath this too. Next.js rotates the opaque action IDs on a schedule, encrypts values an inline action closes over, and ships CSRF protection on action requests. That machinery is real and it matters, but it’s not what this lesson is about; the full security baseline comes later in the course. Name it once, trust that it’s there, and keep your eye on the part that is your job.

That part is this. The encryption protects you against an accidental leak, a server value sliding into the client payload by mistake. It does not protect you against an intentional attack. Remember what the round-trip really is: a public POST whose body is whatever the caller chose to send. Anyone can craft that POST by hand, with any arguments they like. So if your action trusts an argument the client passed, say a userId the form helpfully included so the action “knows who’s calling,” you’ve handed an attacker the controls. They send a different userId, and your action acts on someone else’s behalf.

The discipline here is the golden rule of this entire seam, and the rest of the chapter is built on it:

The encryption guards against accidental leaks; your action body guards against intentional ones. The full auth wrapper that re-reads the session for you lands later in the course. Here we’re setting the posture, because every action you write from now on writes from it.

The skeleton every action in this chapter fills

Section titled “The skeleton every action in this chapter fills”

Put it together. Here is createInvoice as a file-level action whose body is the five seams every action in this chapter follows, in order: parse → authorize → mutate → revalidate → return. This is the spine, and each of the next four lessons fills in one seam.

This skeleton is deliberately non-working: the body is comments rather than code, on purpose. Completing it is the rest of the chapter, so read it as a map rather than as something unfinished. Step through the highlights below.

'use server';
import type { Result } from '@/lib/result';
export async function createInvoice(
formData: FormData,
): Promise<Result<{ id: string }>> {
// 1. parse the input
// 2. authorize the caller
// 3. mutate the database
// 4. revalidate the cache
// 5. return a Result
}

The 'use server' directive, the seam token. This one line is what turns every export below into a public POST endpoint. Read it and hear “network boundary,” exactly as in the round-trip earlier.

'use server';
import type { Result } from '@/lib/result';
export async function createInvoice(
formData: FormData,
): Promise<Result<{ id: string }>> {
// 1. parse the input
// 2. authorize the caller
// 3. mutate the database
// 4. revalidate the cache
// 5. return a Result
}

The signature. FormData is the only argument, the default for forms. The name createInvoice is verb-plus-noun with no Action suffix; it’s the action’s public identity.

'use server';
import type { Result } from '@/lib/result';
export async function createInvoice(
formData: FormData,
): Promise<Result<{ id: string }>> {
// 1. parse the input
// 2. authorize the caller
// 3. mutate the database
// 4. revalidate the cache
// 5. return a Result
}

Parse comes first, always, before any cookie read, database call, or log line. Every later seam needs typed, validated input, so nothing runs until the arguments are checked. You’ll build this with Zod in the next lesson.

'use server';
import type { Result } from '@/lib/result';
export async function createInvoice(
formData: FormData,
): Promise<Result<{ id: string }>> {
// 1. parse the input
// 2. authorize the caller
// 3. mutate the database
// 4. revalidate the cache
// 5. return a Result
}

Authorize next. This is where you re-read who’s calling from the session, never from an argument, per the golden rule above. For now it’s a named slot; the full auth wrapper fills it later in the course.

'use server';
import type { Result } from '@/lib/result';
export async function createInvoice(
formData: FormData,
): Promise<Result<{ id: string }>> {
// 1. parse the input
// 2. authorize the caller
// 3. mutate the database
// 4. revalidate the cache
// 5. return a Result
}

The rest of the body: mutate the database, revalidate the cache so the UI reflects the change, and return a Result the caller can branch on. The remaining lessons fill these in. Result here is only the declared return type; its shape comes the lesson after next.

1 / 1

That Result<{ id: string }> return type is a forward reference. You’ll define Result itself two lessons from now; for today it’s just the promise that this action hands back a structured success-or-failure value rather than throwing. Notice what it lets the signature say: this function returns its outcome rather than throwing it.

The one model to carry out of here: a Server Action is a public POST endpoint disguised as a local function call. await createInvoice(formData) reads local, but it’s a round-trip, and the arguments are attacker-controlled the moment they leave the browser. The contract follows from that: only serializable values cross the wire (never a Drizzle row), and you never trust an identity the client passed (re-read it from the session inside the body).

The five-seam skeleton is the chapter’s roadmap. The next lesson fills in parse; the one after defines the Result the action returns; then comes the thin body discipline that keeps your logic out of the action and in pure helpers; then revalidate and transaction-wrapping the mutate. The authorize seam waits for the auth chapter. By the end of this chapter that skeleton is a working action, and the form that calls it lands right after.