How Server Actions work

Next.js · Expert · 14 min

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 Origin header must match the host; configure serverActions.allowedOrigins behind 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 id from 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 stable NEXT_SERVER_ACTIONS_ENCRYPTION_KEY.

Behaviour to design around:

  • The client dispatches actions one at a time; Promise.all over 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.all to run several Server Actions in parallel from the client; they are dispatched one after another.

Check yourself

  1. A client calls three Server Actions with Promise.all. How are they executed?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: nextjs.org/docs/app/guides/server-actions

Builds on

Unlocks

Nothing builds on this yet.