Skip to content
Chapter 43Lesson 1

Server Actions and the "use server" seam

Server Actions run a database mutation straight from a browser form by calling a server function, 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.

You could build that bridge by hand: add an app/api/invoices/route.ts, write a POST handler, fetch it from the form, set the headers, serialize the body out and parse it back in. That is a lot of plumbing for one form, and all of it is yours to own.

The platform default collapses 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. That is the whole bridge: no route file, no fetch, no hand-rolled serializer. This is a Server Action , and it handles the overwhelming majority of mutations in a 2026 SaaS app.

That convenience is also where the risk lives. await createInvoice(formData) reads like a local function call, but it is not one: every call 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 this lesson teaches is to hold both readings at once, the call that feels local and the network boundary that demands you distrust everything crossing it.

This builds on the 'use server' directive from Directives and enforcement. You will leave with an empty createInvoice skeleton that the rest of this chapter fills in, one seam at a time.

How a Server Action call becomes an HTTP POST

Section titled “How a Server Action call becomes an HTTP POST”

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, but it cannot ship the function body, since that body reads your database and has no place in a bundle anyone can open in DevTools. So the compiler ships a stand-in.

That stand-in is an opaque ID . When you import { createInvoice } in a Client Component, what binds to your variable is that ID, not the source.

So here is what await createInvoice(formData) really does. The client serializes your arguments into the RSC payload , the wire format from the RSC chapter, and opens an HTTP POST carrying the action’s opaque ID and those serialized arguments. The server looks up the ID to find the real createInvoice, runs it with the deserialized arguments, serializes the return value, and sends it back. The client deserializes it, and your await resolves.

That is a full network round-trip, performed transparently every time. From inside your component, nothing at the call site hints at the network: it reads as a local function call from first character to last.

This is where a core principle of the course applies. Prefer explicit over magic. A framework could hide all of this, auto-generating the client and the endpoint so you write nothing. React instead makes you write 'use server'. That directive is the seam made visible, a single token marking where your code stops being local and becomes a network boundary, kept in view precisely because that is what you most need to keep in view. When you read 'use server', hear “public POST endpoint” every time.

Scrub through the round-trip below: the left lane is the browser, the right lane your server.

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.

The directive only matters at the server/client boundary. Call a 'use server' function from a Server Component, which already runs on the server, and there is no POST, no serialization, no opaque ID, just one function calling another in the same process. The round-trip you scrubbed through happens specifically when a client calls the action, and that is the case that matters, because that is where the network and the trust boundary are real.

Where 'use server' goes: file-level or inline

Section titled “Where 'use server' goes: file-level or inline”

You can write 'use server' in two places. Default to file-level; reach for inline only under the one condition below.

A file-level directive is 'use server' on the first line of a module, which turns every exported async function in that file into a Server Action. Keep your actions in their own file, an actions.ts beside the route or feature that uses them. To see what the client can invoke on the server, you open the actions.ts files: the boundary has an address you can search.

An inline directive is 'use server' as the first statement inside an async function defined within a Server Component. Reach for it in one situation only: the action needs to close over a value that exists just for that render, such as a request-scoped ID or derived auth state, that the Client Component must never receive.

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

The default. The directive promotes every exported async function in the file to a Server Action, each in a known, searchable place beside its feature. This is the shape you’ll write for the rest of the chapter.

A Client Component imports an action like any other function, then invokes it in one of three shapes. All three call the same function and let the framework do the POST; they differ only in how the call is triggered and who owns the pending state. We name them here; the next chapter on forms wires them up.

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);

One signature detail to note now: useActionState injects the previous state as the action’s first parameter, so the action it wraps is shaped async function action(prevState, formData), not action(formData). The form-data-only signature and the useActionState signature are not the same shape.

Arguments to a Server Action aren’t passed; they’re serialized into the RSC payload, sent over the POST, and deserialized on the server. So “what can I pass to an action?” really means “what survives that serialization?”, and that set is narrower than “any value I can hold in a variable.”

The wire is the structured-clone-plus-React superset from the RSC chapter: the browser’s structured clone algorithm with React’s additions on top. A value that serializes through it can be an argument; one that doesn’t fails the call.

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 cases are easy to miss. Temporal values like Temporal.PlainDate don’t cross; encode them as ISO strings at the boundary and parse them back inside the action. JSX doesn’t cross either: React elements travel server-to-client on the render wire, but can’t ride into an action’s parameters. The render and action-argument wires overlap but aren’t identical, so a value crossing one doesn’t mean it crosses the other.

This leaves two clean defaults. For forms, take FormData as the only argument and parse it on entry, which you’ll build 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 most common serialization bug at this seam. A Drizzle row looks plain in the debugger: your columns as keys, values reading fine. But it carries a custom prototype, so the moment you pass it to a Server Action, serialization throws. The instinct to “just pass the invoice I already loaded” to archiveInvoice(invoice) fails on exactly this. The fix is almost always to pass the ID and let the action re-read what it needs: archiveInvoice(invoice.id). If you truly must ship a whole row, JSON.parse(JSON.stringify(row)) strips the prototype to a plain object, but treat it as an escape hatch, not a default.

Sort each of these. A couple are the exact decoys that get missed 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')

The action stays on the server, its arguments are attacker-controlled

Section titled “The action stays on the server, its arguments are attacker-controlled”

Because the client holds only the opaque ID, the action’s source never ships to the browser, so importing an action into a Client Component costs nothing in bundle size. You can keep the action in a feature file full of 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 the ID, not the body. Unused action exports are dead-code-eliminated at build time.

Next.js layers a security model on top: it rotates the opaque IDs, encrypts values an inline action closes over, and adds CSRF protection. The full baseline comes later in the course.

But that machinery only guards against an accidental leak, a server value sliding into the client payload by mistake. It does not stop an intentional attack. The round-trip is a public POST whose body is whatever the caller chose to send, and 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 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.

This is the golden rule of the entire seam, and the rest of the chapter is built on it:

The auth wrapper that re-reads the session for you arrives later in the course. Here we are setting the posture every action you write starts from.

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. The body is comments you’ll fill in: the next lesson writes parse, the lessons after it write mutate, revalidate, and return, and authorize waits for the auth chapter.

'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 turns every export below into a public POST endpoint. Read it and hear “network boundary.”

'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: re-read who’s calling from the session, never from an argument. For now it’s a named slot; the 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. Result here is only the declared return type; its shape comes the lesson after next, where the action returns its outcome rather than throwing it.

1 / 1