#Server Actions
AddedA Server Action is an async function that runs on the server but that your client code can call as if it were local. You mark it with the 'use server' directive, pass it to a <form action> or call it from an event handler, and React and Next.js do the rest: they serialise the arguments, send them to the server, run the function, and send back its return value, plus a freshly rendered page if the action changed something the page shows. React calls the general feature a Server Function; "Server Action" is the name for a Server Function used for a mutation, through a form or a transition. Next.js uses both names, and so will this chapter.
The problem it solves is the boilerplate of every write in a classic single-page app: an API route, a fetch with a JSON body, loading flags, error handling, then refetching or patching client state so the screen shows the change. With an action, the mutation, the cache invalidation and the updated UI travel in one round trip, and a form keeps working before the JavaScript has loaded.
The mental model to keep: a Server Action is a hidden API endpoint whose URL is an ID. At build time, the compiler replaces the function in the client bundle with a small stub that knows an action ID. Calling the stub sends a POST to the current page's URL with a Next-Action: <id> header and the serialised arguments in the body. The server looks the ID up, runs the real function, and streams back the result in React's Flight format (text/x-component). Nothing about the function is secret except its body: anyone who can send that POST can run it, which is why the security topic later in this chapter exists.
Here is a small guestbook that uses all three ways of reaching an action: a form in a Server Component, a button in a Client Component, and an action defined inline inside a Server Component. The page comes first; the actions file, the like button, the store and the route table follow under "The rest of the guestbook". The "database" is an array kept on globalThis, so every route in the one next start process sees the same data.
import { Suspense } from "react";
import { connection } from "next/server";
import { deleteEntry, listEntries } from "@/lib/guestbook";
import { refresh } from "next/cache";
import { signGuestbook } from "./actions";
import { LikeButton } from "./like-button";
async function Entries() {
await connection(); // read the store on every request
const entries = await listEntries();
return (
<ul>
{entries.map((entry) => {
// An inline Server Action: it closes over `entry.id` from this render.
async function remove() {
"use server";
await deleteEntry(entry.id);
console.log(`[remove] entry ${entry.id}`);
refresh();
}
return (
<li key={entry.id}>
<b>{entry.name}</b>: {entry.message} <LikeButton id={entry.id} initial={entry.likes} />
<form action={remove}>
<button>Delete</button>
</form>
</li>
);
})}
</ul>
);
}
export default function Page() {
return (
<main>
<h1>Guestbook</h1>
<form action={signGuestbook}>
<input name="name" placeholder="Name" required />
<input name="message" placeholder="Message" required />
<button>Sign</button>
</form>
<Suspense fallback={<p>Loading entries…</p>}>
<Entries />
</Suspense>
</main>
);
}app/actions.ts(below) starts with"use server", so every export of that file becomes a Server Action:signGuestbookandlike. They importlib/guestbook.ts, which starts withimport "server-only", so the store can never end up in a browser bundle.- The page is a Server Component. It passes
signGuestbookstraight to<form action>. Because a Server Component never runs in the browser, Next.js renders this form as plain HTML that can submit without JavaScript (shown in "What the server renders"). LikeButtonis a Client Component that importslike. What it actually gets is a stub.await like(id)insidestartTransitionsends a POST and resolves with the server's return value, sosetLikes(next)shows the count the server computed:0 → 1.removeis defined inside the map callback, with"use server"as its first line. It closes overentry.id, a value from this particular render. The server can't keep that value around between requests, so Next.js puts it in the HTML, encrypted, and the browser sends it back when the form is submitted.Entriesawaitsconnection(), which makes it render per request inside its<Suspense>; the heading and the form are the static shell. That's what the build's◐ /(Partial Prerender) says.signGuestbookandremoveend withrefresh(), which tells Next.js to re-render the current page and send it back in the same response as the action.likedoesn't, so its response carries only the number.
The actions file, the like button, the store, and the route table next build printed:
"use server";
import { refresh } from "next/cache";
import { addEntry, likeEntry } from "@/lib/guestbook";
export async function signGuestbook(formData: FormData) {
const name = String(formData.get("name") ?? "").trim();
const message = String(formData.get("message") ?? "").trim();
if (!name || !message) return;
const entry = await addEntry(name, message);
console.log(`[signGuestbook] #${entry.id} ${name}: ${message}`);
refresh(); // re-render the current page in this same response
}
export async function like(id: number) {
const likes = await likeEntry(id);
console.log(`[like] entry ${id} now has ${likes} like(s)`);
return likes;
}"use client";
import { useState, useTransition } from "react";
import { like } from "./actions";
export function LikeButton({ id, initial }: { id: number; initial: number }) {
const [likes, setLikes] = useState(initial);
const [pending, startTransition] = useTransition();
return (
<button
disabled={pending}
onClick={() =>
startTransition(async () => {
const next = await like(id); // looks like a function call; it's a POST
setLikes(next);
})
}
>
♥ {likes}
</button>
);
}import "server-only";
export type Entry = { id: number; name: string; message: string; likes: number };
// A stand-in database: one array per server process, kept on globalThis so every route sees the same one.
const store = globalThis as unknown as { guestbook?: Entry[] };
const entries = (store.guestbook ??= [{ id: 1, name: "Ada", message: "First!", likes: 0 }]);
export async function listEntries(): Promise<Entry[]> {
return entries.map((e) => ({ ...e }));
}
export async function addEntry(name: string, message: string): Promise<Entry> {
const entry = { id: entries.length + 1, name, message, likes: 0 };
entries.push(entry);
return entry;
}
export async function likeEntry(id: number): Promise<number> {
const entry = entries.find((e) => e.id === id);
if (!entry) throw new Error(`No entry ${id}`);
return ++entry.likes;
}
export async function deleteEntry(id: number): Promise<void> {
const i = entries.findIndex((e) => e.id === id);
if (i >= 0) entries.splice(i, 1);
}Route (app)
┌ ◐ /
└ ○ /_not-found
○ (Static) prerendered as static content
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed contentFetching the page with curl -s http://localhost:4507/ (after next build && next start -p 4507) shows what React did with the two forms. Picked out of the HTML:
<form action="" encType="multipart/form-data" method="POST">
<input type="hidden" name="$ACTION_ID_40fb874c8cb70c4ff73d15839a25eea205687d62cb"/>
<input placeholder="Name" required="" name="name"/>
<input placeholder="Message" required="" name="message"/>
…
<form action="" encType="multipart/form-data" method="POST">
<input type="hidden" name="$ACTION_REF_2"/>
<input type="hidden" name="$ACTION_2:1" value="["$@2"]"/>
<input type="hidden" name="$ACTION_2:0" value="{"id":"40cd9d8197dde780f549d8c83c11a70e5af27ee332","bound":"$@1"}"/>
<input type="hidden" name="$ACTION_2:2" value=""4dLfqAWX2oBd8ietP9aKX4aPmCvEI0+bGLLOjTOZLYTlBlkXexFClG+JQiYkgqEsJofVgG2Ck68F/bzHNf/Vf4XMMVevr9pWggTeR7qW6N0=""/>- Both forms are ordinary HTML forms:
method="POST",encType="multipart/form-data"andaction="", which means "post to the URL I'm on". A browser can submit them with no JavaScript at all. - The first form carries the action ID as the name of a hidden input,
$ACTION_ID_40fb87…. When the browser posts the form, the server finds that field and knows which function to run. - The second form is the inline
removeaction.$ACTION_2:0says "action40cd9d…with bound arguments$@1", and$ACTION_2:2is the closed-overentry.id, encrypted: 80 bytes of base64-encoded ciphertext instead of1. The browser can carry it but can't read or change it. - The IDs are 42 hex characters. They're generated at build time, look random, and differ between builds; they are not derived from the function name.
With JavaScript running, clicking ♥ on Ada's entry doesn't post a form; the stub makes a fetch. Recorded in headless Chrome over the DevTools protocol (Network.requestWillBeSent, Network.responseReceived and Network.getResponseBody; headers trimmed to the ones that matter):
POST http://localhost:4507/
next-action: 401e09601c3122ef962683d4e71a554ed7f9720ee4
next-router-state-tree: %5B%22%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%2C49314%5D%7D%2Cnull%2Cnull%2C49234%5D
Accept: text/x-component
Content-Type: text/plain;charset=UTF-8
body: [1]
200 OK
Content-Type: text/x-component
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
body:
0:{"a":"$@1","q":"","i":false,"b":"MiaXIN1sLAbwao82jfqSU"}
1:1Submitting the sign form with JavaScript running sends the same kind of request, but as multipart/form-data, because the argument is a FormData:
POST http://localhost:4507/
next-action: 40fb874c8cb70c4ff73d15839a25eea205687d62cb
Accept: text/x-component
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary8JZYQIN43gEXErBh
body (one field per line):
_1_$ACTION_ID_40fb874c8cb70c4ff73d15839a25eea205687d62cb = ""
_1_name = "Linus"
_1_message = "Hello from JS"
0 = ["$K1"]
200 OK
x-action-revalidated: 2
Content-Type: text/x-component- Both calls are a
POSTto the page's own URL. Next.js tells an action request apart from a page request by thenext-actionheader, whose value is the action ID from the build. like(1)has one plain argument, so React serialises the argument list as JSON: the body is literally[1].- The response row
0:is the envelope:"a":"$@1"means "the action's result is in row 1". Row1:1is the return value, the new like count. There is no page tree in it:likedidn't callrefresh(), so Next.js didn't re-render anything, and the button's ownsetLikesupdates the screen. - The form's
FormDatais sent as multipart. React prefixes the fields with_1_and sends the argument list["$K1"]("argument 0 is the form data with prefix 1"); the server rebuilds aFormDatafrom the prefixed fields before callingsignGuestbook. signGuestbookcalledrefresh(), so the response hasx-action-revalidated: 2and carries the re-rendered page as well (replayed withcurl, the body was 5,514 bytes of Flight rows starting with the page tree). Chrome showed Linus's entry, the inputs were cleared, and awindow.__markerset before the click was still there: no page load happened.next-router-state-treedescribes which layouts and pages the browser currently has, so the server can send back only the parts of the tree that need to change.
Nothing in that request needs a browser. Sending the like request with curl and no cookies, no Origin header and no page visit first:
curl -s -D - -X POST http://localhost:4507/ \
-H "Next-Action: 401e09601c3122ef962683d4e71a554ed7f9720ee4" \
-H "Content-Type: text/plain;charset=UTF-8" \
--data '[2]'HTTP/1.1 200 OK
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Content-Type: text/x-component
0:{"a":"$@1","q":"","i":false,"b":"MiaXIN1sLAbwao82jfqSU"}
1:1The server log printed [like] entry 2 now has 1 like(s): Grace's entry got a like from a terminal. Two more things the same experiment showed:
- The URL hardly matters. Posting the same request to
http://localhost:4507/nope, a path with no page, returnedHTTP/1.1 404 Not Foundand still ran the action: the log printed[like] entry 2 now has 1 like(s)and the body ended1:1. The action ID picks the function, not the path. - An unknown ID is rejected cleanly. A made-up ID returned
HTTP/1.1 409 Conflictwithx-nextjs-action-not-found: 1and the bodyServer Action unavailable.; the log saidFailed to find Server Action "40deadbeef…". This request might be from an older or newer deployment.That's also what an old browser tab sees after you deploy a new build.
What protects the inline remove action's entry.id? Two experiments with the hidden fields copied out of the HTML and posted back with Node's fetch (as a no-JavaScript browser would post them, with Origin: http://localhost:4507):
tampered: one character of $ACTION_2:2 changed → 500 text/plain
server log: ⨯ Error [OperationError]: The operation failed for an operation-specific reason
[cause]: [Error: Cipher job failed]
replayed: the fields exactly as served → 200 text/html; charset=utf-8
server log: [remove] entry 1- The closed-over value is encrypted with an authenticated cipher. Changing a single character makes decryption fail ("Cipher job failed"), so the action never runs: a user can't edit the ciphertext to turn "delete entry 1" into "delete entry 2".
- Sending the fields back unchanged works perfectly, even from outside the browser, and Ada's entry was gone afterwards. Encryption stops tampering and stops the value being read; it does not stop someone from calling the action with values your page once gave them.
- So a closure is a good way to carry a snapshot from render time (a version number to detect conflicts, an ID the page was showing). It is not an authorisation check:
removeshould still verify that the caller may delete that entry.
'use server'- Two placements. At the top of a file, every export becomes an action (the only way to define actions that Client Components can import). As the first statement of an async function inside a Server Component, only that function does, and it can close over the component's variables.
- Only async functions can be exported from a
'use server'file, because each export becomes a remote call and remote calls are asynchronous. A file that also exports a constant fails the build (path shortened):
Error: Turbopack build failed with 2 errors:
./src/app/actions.ts:3:1
Error: Only async functions are allowed to be exported in a "use server" file.
1 | "use server";
2 |
> 3 | export const MAX_LENGTH = 280;
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^- You can't define an action inside a Client Component; import it from a
'use server'file, or receive it as a prop from a Server Component (actions are the one kind of function that can cross the server/client boundary as a prop; see Passing data across the boundary). - Arguments and return values must be serialisable by React: primitives, plain objects and arrays,
Date,Map,Set,FormData, typed arrays, Promises. Class instances and ordinary functions are not. 'use server'is not "server-only". It means "callable from the client". A helper that must never be callable belongs in a module withimport "server-only", not in a'use server'file.
next build writes .next/server/server-reference-manifest.json, which maps each ID to its module, export name and the pages that can run it. For the guestbook (paths shortened):
40cd9d8197dde780f549d8c83c11a70e5af27ee332 $$RSC_SERVER_ACTION_0 src/app/page.tsx ['app/page']
401e09601c3122ef962683d4e71a554ed7f9720ee4 like src/app/actions.ts ['app/page']
40fb874c8cb70c4ff73d15839a25eea205687d62cb signGuestbook src/app/actions.ts ['app/page']Two production consequences follow. New builds usually get new IDs (Next.js rotates them at least every 14 days even when the code doesn't change), so a tab still running the previous build can hit the 409 "Failed to find Server Action" you saw above: deploy with rolling updates and treat that error as "please refresh". And the closure encryption key is generated per build, so when you run several instances, set NEXT_SERVER_ACTIONS_ENCRYPTION_KEY (a base64 AES key, e.g. from openssl rand -base64 32) to the same value everywhere, or one instance can't decrypt what another rendered.
| Version | What changed |
|---|---|
| Next.js 13.4 | Server Actions arrive as an alpha, behind experimental: { serverActions: true }. |
| Next.js 14 | Server Actions are stable and on by default. Pending/error state comes from useFormState and useFormStatus in react-dom (React canary). |
| Next.js 15 | React 19: useFormState is replaced by useActionState from react. Action IDs become unguessable and rotate between builds; unused actions are removed from the build. next/form and unstable_rethrow are added. React's docs start saying "Server Functions". |
| Next.js 16 | updateTag and refresh (Server Actions only) are added; revalidateTag takes a second argument, a cache profile such as 'max'. Cache Components ('use cache', cacheTag) become the caching model these functions invalidate. |
Interviews still describe actions as "experimental" or show useFormState; both are pre-15 knowledge.
Someone copies the like request from DevTools and replays it with curl, but to http://localhost:4507/does-not-exist instead of /. What status do they get, and does the like count change?
Show answer
They get 404 Not Found, and the count does change.
- Next.js routes the request by path first: there's no page at
/does-not-exist, so the response status becomes 404. - But the request carries
Next-Action: 401e…. The action handler looks that ID up in the manifest, findslikeinsrc/app/actions.ts, and runs it with the decoded body[2]. - Observed on 16.4.3: status
404 Not Found, body1:1, and the log line[like] entry 2 now has 1 like(s). - The lesson: you can't protect an action by guarding a URL (in proxy, a CDN rule or a page's own check). The only place a check reliably runs is inside the action.