Defining a Server Action

'use server' marks server code. On the first line of a file, every export becomes a Server Action; on the first line of an async function in a Server Component, only that function does. Prefer the file form, which a Client Component can import from — it can never define one of its own.

A file of actions (app/notes/actions.js)JavaScript
"use server";
import { revalidatePath } from "next/cache";
// currentUser() reads the session cookie; addNote() writes to the store
export async function createNote(prevState, formData) {
  const user = await currentUser();
  console.log("[action] createNote by", user?.name ?? "anonymous");
  if (user?.role !== "editor") return { error: "You may not add notes." };
  await new Promise((r) => setTimeout(r, 900));    // stand in for a slow database
  const note = addNote(String(formData.get("text")).trim(), user.id);
  revalidatePath("/notes");
  return { error: null, saved: note.text };
}

Two rules are compiler-enforced: the function must be async, because calling it crosses a network, and its arguments and return value must serialize by the same rules as props (Serializing Props) — a Date and a Map survive, a class instance and a Mongoose 243,355 document do not.

Submit the form and the log appears in the terminal running next dev, never in the browser:

Output of 42
[action] createNote by Ada
 POST /notes 200 in 951ms (next.js: 8ms, application-code: 943ms)
  └─ ƒ createNote({"error":null}, {}) in 914ms actions.js

Search the client bundle for that function body and you will not find it: Turbopack 10,514 emits createServerReference("6064...e653", callServer, ...) in its place, and the dispatcher around it POSTs to the current URL with that action ID in a Next-Action header. The action is an endpoint, then — as curl 3,008 shows, with no browser, form or cookie:

Invoking a Server Action directlyShell
curl -X POST http://localhost:3507/notes -H "Content-Type: text/plain" \
  -H "Next-Action: 60642d5789a87b0aae1e954fd2e8a9c4856dc7e653" --data '[{},{}]'
Output
0:{"a":"$@1","f":"","q":"","i":true,"b":"development"}
1:D"$2"
1:{"error":"You may not add notes."}

The action ran. What stopped it was the role check inside the function, not the absence of a form for anonymous visitors. Rendering a form only for people allowed to use it is a courtesy, and the Next.js 10,514 documentation calls it no security boundary: every action needs its own session lookup and ownership check.

Three framework guards, and the config keys that tune them
Guard Default Where to change it
Origin vs Host check On serverActions.allowedOrigins
Request body limit 1 MB serverActions.bodySizeLimit
Closure variables encrypted On NEXT_SERVER_ACTIONS_ENCRYPTION_KEY

The first is a free CSRF check: a POST carrying Origin: https://evil.example.com is aborted with "Invalid Server Actions request" before your code runs. Both config keys live under experimental. Action IDs are encrypted and unused actions stripped at build, so a function nobody calls has no endpoint; set the key wherever more than one instance runs, or instances cannot decrypt each other's references.