Functions can't cross the boundary. Not because React is fussy, but because the two sides never share a scope — there's no lexical environment on the client for a server closure to be captured from. Props go into a text payload. A function isn't text.
And yet:
// server
async function likePost(id: string) {
'use server'
await db.likes.create({ postId: id })
}
// client
<LikeButton onLike={likePost} />That works. A function, passed across the boundary, called from the browser.
Whether you'd noticed the contradiction, or just used it. Both are honest answers and the second is more common.
The resolution is the same trick as post 5, pointed the other way. The function doesn't cross. A reference does.
What "use server" compiles to
Post 5: when the bundler builds the server graph and finds "use client", it replaces the module with a stand-in carrying an address.
This is the mirror. When the bundler builds the client graph and finds "use server", it replaces the function body with a stub. The stub holds an ID — a deterministic hash of the file path and the export name, generated at build time the way post 4's module IDs were.
Your server code is not in that stub. It was never in the client graph at all.
Calling it does this:
POST /whatever-route-you-are-on
Next-Action: 7f2a9c4e1b...
[serialized arguments]An HTTP request to your own origin, with an action ID in a header and the arguments in the body. The server looks the ID up in a manifest — a second one, the mirror of post 5's client manifest — finds the real function, and calls it.
So a Server Action is an RPC endpoint that you didn't write a route for. The framework generated the ID, the route, the serialization and the dispatch. You wrote a function and a directive.
A detour: what RPC actually means
Worth unpacking, because the term gets used casually and it's the whole shape of what's happening here.
RPC is Remote Procedure Call. The idea is old — 1980s old — and the goal has always been the same: make calling code on another machine look exactly like calling a function locally. You write deletePost(42). Something serializes the argument, sends it over a network, runs the function elsewhere, serializes the result, sends it back. The remoteness is hidden on purpose.
The useful contrast is REST. REST models nouns: you have resources, and you act on them with a small fixed set of verbs. DELETE /posts/42. RPC models verbs: you have operations, and you call them. deletePost(42). Neither is better; they're different framings of the same network call. REST won on the public web because resources and HTTP verbs map neatly onto caching, proxies and URLs. RPC never went away for service-to-service work, where you don't need any of that and you do want a function call.
gRPC is the modern version you've probably heard of. Google's, from 2015, and it works like this: you write a schema file describing your service and its methods, a code generator turns that schema into client and server stubs in your language, and you call methods on the stub. The stub handles serialization — Protocol Buffers, a compact binary format — and transport over HTTP/2. From your code's point of view, it's a method call.
Now line that up against what you just read.
gRPC: you write a schema, a code generator produces a stub, calling the stub does the network work
Server Actions: you write a directive, the bundler produces a stub, calling the stub does the network work
Structurally the same thing. And the difference is the part that matters.
gRPC has a contract. Server Actions don't. That schema file is enforced at both ends — the generated server stub rejects a payload that doesn't match the declared types before your code ever sees it. A Server Action has no schema. The bundler generated an ID and a dispatcher, and nothing anywhere describes what a valid call looks like. Your function signature says (id: string), but TypeScript is erased at build time. What actually arrives is whatever the caller sent.
That's why the next section exists, and it's why validation inside the action isn't defensive programming. It's the only contract there is.
There's an older lesson under this too. RPC's history is a long series of attempts to make the network invisible, and the recurring result is that you can't. Remote calls fail in ways local calls don't, take time local calls don't, and — the one people forget — can be made by anyone, not just by your code. Every RPC system eventually rediscovers this. Server Actions are the newest and most ergonomic instance, and the ergonomics are exactly what make it easy to forget.
Every action is a public endpoint
That last sentence has a consequence, and it is the single most important thing in this post.
The action ID is in your client bundle. It's in the HTML. Anyone can read it, and anyone can POST to it, with any arguments they like, from anywhere.
Which means this is not protection:
export default async function Page() {
const user = await getUser()
return (
<>
{user.isAdmin && <DeleteButton onDelete={deletePost} />}
</>
)
}Hiding the button hides the button. The action still exists, still has an ID, and still runs for anyone who sends the request. You have written an unauthenticated delete endpoint and put a conditional render in front of it.
Every action needs its own check, inside the function:
async function deletePost(id: string) {
'use server'
const user = await getUser()
if (!user?.isAdmin) throw new Error('Unauthorized')
await db.posts.delete({ id })
}The mental shift: stop thinking of an action as a function you're calling and start thinking of it as a route you're publishing. You wouldn't ship an API route with no auth check because the UI doesn't link to it. This is the same thing with better ergonomics and worse instincts around it.
This is easy to miss!
This surface has already produced real vulnerabilities — CVE-2025-55182, a remote code execution bug in the Flight deserialization path, and a separate denial-of-service issue. The attack surface is exactly what you'd expect once you accept that your app accepts serialized data at generated endpoints.
What travels with the call
The arguments are serialized, with the same rules as props from post 3. Primitives, plain objects, arrays, Date, Map, Set, FormData. Not functions, not class instances.
Then there's the part that catches people.
export default async function Page() {
const apiKey = process.env.SECRET_KEY
async function doThing() {
'use server'
await callApi(apiKey) // closes over a server value
}
return <ClientForm action={doThing} />
}An inline action defined inside a component closes over that component's scope. But the action is called from the browser, which doesn't have that scope — so the closed-over values have to travel with the reference, out to the client and back.
Next encrypts them, so it isn't a plaintext leak. But they're still crossing the boundary, on every render, and the encryption is the only thing between that value and a bundle someone can download. Treating an inline closure as "server-side, therefore safe" is the wrong instinct.
Read the environment variable inside the action instead of closing over it. Costs nothing, and the value never leaves.
Forms work before your JavaScript does
<form action={createComment}>
<textarea name="body" />
<button>Post</button>
</form>Post 3 covered the hydration gap — HTML painted, nothing interactive yet. Post 6 covered content arriving before React wakes up. This is the third instance of the same theme.
If a user submits that form before hydration finishes, it still works. The browser does a native form POST to the current URL, the framework routes it to the action by ID, and the response comes back as a full document.
After hydration, React intercepts the submit and does it as a fetch instead. Same action, same code, different transport.
That's real progressive enhancement, and you get it by using action instead of onSubmit. Reach for onSubmit and a handler and you've opted out of it without noticing.
The response is a tree
Here's the payoff, and it's the thing that makes Server Actions different from every RPC layer you've used before.
The POST returns a Flight payload. Not JSON.
It contains the action's return value, and — if the action revalidated anything — a freshly rendered tree for the affected route. React reconciles that tree into the page.
So the full loop is:
- 1.
Client calls the reference
- 2.
POST goes out with the ID and arguments
- 3.
Server runs the function, writes to the database
- 4.
revalidatePathorrevalidateTagmarks the affected route stale - 5.
The server re-renders it, in the same request
- 6.
The response carries the return value and the new tree
- 7.
React reconciles
One round trip. Not "mutate, then refetch." The mutation response is the refetch.
That's why post 3 said mutations return trees. Everything since has been building toward the fact that a tree is a thing you can send over a wire, and this is where that pays off — the server can reply to a write with a new view of the world, and the client can just diff it in.
revalidatePath and revalidateTag are the trigger for step 5. What they actually invalidate, and what "stale" means across the four caching layers, is the last post in this series. Here they're just the switch that says this route needs re-rendering before you reply.
The hooks
Four of them, each solving a specific problem that falls out of the above.
useActionState wraps an action and gives you back the last result and a pending flag. It's the one to reach for by default, because it keeps working before hydration — the form still submits natively, and the state hooks up once React is running.
useFormStatus reads the pending state of the nearest enclosing form. It has to be called from a component inside the <form>, not the one that renders it, which is a constraint people trip on constantly. If it always reports false, that's why.
useTransition for actions not attached to a form — a like button, a delete. Gives you a pending flag without a form to read from.
useOptimistic shows the expected result immediately and reconciles when the real one lands. The part worth knowing: you don't write the rollback. The optimistic value is discarded automatically when the action settles and the real state arrives. If the action failed, the real state is the old state, and the UI reverts on its own.
Redirects and errors
redirect() works by throwing. That's not a quirk to work around, it's the mechanism — it throws a special error the framework catches and turns into a redirect.
Which means this breaks:
async function createPost(data: FormData) {
'use server'
try {
await db.posts.create(...)
redirect('/posts') // throws
} catch (e) {
return { error: 'Failed' } // swallows the redirect
}
}The redirect never happens, and the user gets a generic error on a post that saved fine.
The fix isn't a Next.js API. It's the general rule that a broad catch is almost always a bug: catch what you expect, not everything. This is the same failure as swallowing an AbortError or a cancellation signal — control flow passing through a try that had no business intercepting it.
So narrow the try to the thing that can actually fail:
async function createPost(data: FormData) {
'use server'
let post
try {
post = await db.posts.create({ ... })
} catch (e) {
return { error: 'Could not save your post' }
}
redirect(`/posts/${post.id}`) // outside the try
}That's the answer in nearly every case, and it makes the code better on its own terms — the try now wraps one fallible operation instead of a whole function body.
If you genuinely can't restructure — a shared error-reporting wrapper, a helper you don't control — Next provides an escape hatch that lets framework errors pass through:
import { unstable_rethrow } from 'next/navigation'
try {
await doTheThing()
redirect('/posts')
} catch (e) {
unstable_rethrow(e) // framework errors pass through
return { error: 'Could not save your post' }
}Note the prefix. It's unstable_ because the underlying mechanism — control flow via thrown sentinels — is something React and Next want room to change. Treat it as a patch for a try block that's too wide, not as the standard way to write actions.
notFound() works the same way and breaks in the same place, so a catch that swallows one swallows both. If you have a broad catch anywhere near navigation calls, that's where to look first.
For real errors: in production, an error thrown from an action reaches the client as a generic message with a digest ID, not your actual error text. That's deliberate — action errors can contain database messages, file paths, query fragments. If you want the user to see something specific, return it rather than throwing it. Thrown means unexpected; returned means handled.
What's next
That's the loop closed. A request comes in, the server produces a tree, the browser renders it, streams what wasn't ready, and can now send data back and receive a new tree in reply.
Which raises the question this whole series has been walking toward. If the server owns the data, the fetching, the mutations and the re-rendering — what is actually left for the client to hold?
Less than you think, and not the things you'd guess.
If this helped, or if I got something wrong — find me on LinkedIn or my site. I read everything.