Understanding the wire protocol prevents security holes, performance traps and production errors after deploys.
At build time the 'use server' directive makes the compiler replace a Server Function in client bundles with a reference: an encrypted, non-deterministic action ID. Calling it sends a POST request to the current page with the ID and serialised arguments; the server looks the function up, runs it and streams back the result (and possibly a fresh RSC payload). Only POST can invoke actions.
Built-in protections:
- CSRF: the
Originheader must match the host; configureserverActions.allowedOriginsbehind proxies. - Body size is capped at 1 MB by default (
serverActions.bodySizeLimit). - Dead code elimination: actions not referenced by client code have no public endpoint.
- Closures: values captured by an inline action (like an
idfrom the page) are encrypted and sent to the client and back. Don't rely on that for secrets; pass only references (ids) and re-read data on the server. In multi-instance deployments set a stableNEXT_SERVER_ACTIONS_ENCRYPTION_KEY.
Behaviour to design around:
- The client dispatches actions one at a time;
Promise.allover several actions won't parallelise them. Do parallel work inside one action, or use Route Handlers for non-mutating requests. - Actions are for mutations, not data fetching.
- New deployments generate new IDs; a tab still running the old build gets "Failed to find Server Action". Prefer rolling deploys and offer a refresh.
Example
// app/items/[id]/page.tsx
import { db } from '@/lib/db'
import { verifySession } from '@/lib/dal'
export default async function ItemPage({ params }: PageProps<'/items/[id]'>) {
const { id } = await params
const item = await db.item.find(id)
async function archive() {
'use server'
// `id` is a captured closure value: encrypted, sent to the client and back.
// Still treat it as untrusted input: re-check the session and ownership here.
const { userId } = await verifySession()
const current = await db.item.find(id)
if (current?.ownerId !== userId) throw new Error('Forbidden')
await db.item.update({ where: { id }, data: { archived: true } })
}
return (
<form action={archive}> {/* POST /items/42 with the action ID */}
<h1>{item?.name}</h1> <button>Archive</button>
</form>
)
}
// next.config.ts: serverActions: { allowedOrigins: ['admin.example.com'], bodySizeLimit: '2mb' }Remember
- Actions compile to encrypted IDs invoked by POST to the current page
- CSRF origin check, 1 MB body limit and dead code elimination are built in
- Captured closure values are encrypted but must still be treated as untrusted
- Clients dispatch actions sequentially; deploys rotate action IDs
Watch out for
- Using
Promise.allto run several Server Actions in parallel from the client; they are dispatched one after another.