Concept
The beginner framing: Server Actions let a <form> call server-side code directly, without hand-building an API route and a fetch call to reach it.
The precise mental model, and a terminology distinction worth being precise about: a Server Function is any asynchronous function marked with "use server", it runs on the server and can be called from the client over the network. A Server Action is specifically a Server Function used for a mutation, in a form or transition context, every Action is a Function, but not every Function is used as an Action.
// app/lib/actions.ts
"use server";
export async function createPost(formData: FormData) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized"); // ⚠️ ALWAYS check, see Common Mistakes
const title = formData.get("title");
await db.post.create({ data: { title } });
}// app/ui/form.tsx
import { createPost } from "@/app/lib/actions";
export function Form() {
return (
<form action={createPost}>
<input name="title" />
<button type="submit">Create</button>
</form>
);
}Passing a Server Function to a <form>'s action prop (or a <button>'s formAction) is what makes it a Server Action by convention, behind the scenes, this always uses POST, and Next.js returns both the mutation's result and fresh UI in a single server round-trip.
<form action={createPost}><input name="title" /><button>Create</button></form>
Submitting the form invokes the Server Action directly, React extends <form>'s action prop to accept a Server Function. Behind the scenes, this is a POST request.
Progressive enhancement, for free
Because forms invoking Server Actions use the standard HTML <form action> mechanism, they submit correctly even before JavaScript has loaded, or with JavaScript disabled entirely, this is a genuine, built-in benefit, not something you opt into separately.
Revalidating and redirecting after a mutation
"use server";
import { updateTag } from "next/cache";
import { redirect } from "next/navigation";
export async function createPost(formData: FormData) {
const post = await db.post.create({ data: { title: formData.get("title") } });
updateTag("posts"); // invalidate cached "posts" data immediately
redirect(`/posts/${post.id}`); // throws a control-flow exception, code after this never runs
}redirect() throws internally to short-circuit execution, anything written after it in the function body never executes, so revalidation calls must come before it, not after.
Cookies inside a Server Action
"use server";
import { cookies } from "next/headers";
export async function setTheme(theme: string) {
(await cookies()).set("theme", theme);
}Setting or deleting a cookie inside a Server Action causes Next.js to re-render the current page and its layouts on the server, client state is preserved for components that re-render, and effects re-run only if their dependencies actually changed, exactly the ordinary re-render rules from Effects.
Showing pending state
"use client";
import { useActionState } from "react";
import { createPost } from "@/app/actions";
export function Form() {
const [state, formAction, pending] = useActionState(createPost, null);
return (
<form action={formAction}>
<button disabled={pending}>{pending ? "Creating..." : "Create Post"}</button>
</form>
);
}Try It
Predict what happens before checking the solution.
"use server";
export async function createPost(formData: FormData) {
const post = await db.post.create({ data: { title: formData.get("title") } });
redirect(`/posts/${post.id}`);
revalidateTag("posts"); // ⚠️ written AFTER redirect
}Does the posts tag actually get revalidated?
Solution
No. redirect() throws a control-flow exception the moment it's called, everything written after it in the function body, including revalidateTag("posts") here, never executes. The fix is to reorder: call revalidateTag("posts") (or updateTag) before redirect(...), so the invalidation actually happens before execution is short-circuited.
Implement It Yourself
Model the expected-vs-uncaught error distinction Server Actions are meant to follow:
// EXPECTED errors (validation, a failed but "normal" outcome) → RETURN a value
async function createPostExpectedErrorStyle(prevState, formData) {
const title = formData.get("title");
if (!title) {
return { message: "Title is required" }; // returned, not thrown
}
const result = await api.createPost({ title });
if (!result.ok) {
return { message: "Failed to create post" }; // still returned
}
return { message: null };
}
// UNCAUGHT exceptions (a genuine bug, an unexpected failure) → THROW
async function
This mirrors the documented guidance directly: expected, "this can normally happen" failures are modeled as return values consumed via useActionState's state, while genuinely unexpected failures should throw, propagating to the nearest error boundary rather than being silently absorbed as ordinary state.
Under the Hood
Server Actions are built directly on the Actions/useActionState model from React 19 Actions, Next.js supplies the transport (a real HTTP POST, a single round-trip returning both mutation result and fresh UI) around the same React primitive. And the "expected errors as return values, not throw/catch" guidance mirrors ordinary Error Handling discipline generalized to a UI context: a value that represents a normal, anticipated outcome (validation failed) shouldn't use the same channel as a value representing a genuine, unexpected bug.
Common Mistakes
1. Trusting the UI as the only way a Server Action gets called
"use server";
export async function deletePost(id: string) {
await db.post.delete({ where: { id } }); // ❌ no auth check at all
}The docs are explicit: "Server Functions are reachable via direct POST requests, not just through your application's UI." Every Server Function must independently verify authentication and authorization, there's no implicit trust boundary just because it "looks like" it's only called from your own form.
2. Writing revalidation/redirect code in the wrong order
Covered in Try It, redirect() throws, so anything after it never runs. Always revalidate, then redirect, never the reverse.
3. Using throw/try-catch for expected, normal-outcome errors
export async function createPost(formData: FormData) {
try {
// ...
} catch (e) {
throw e; // ❌ an expected validation failure shouldn't propagate as an uncaught exception
}
}The docs specifically recommend modeling expected errors (failed validation, a normal "this didn't work" outcome) as return values consumed by useActionState, reserving throw for genuinely unexpected exceptions.
4. Expecting parallel execution when dispatching multiple Server Functions from the client
The client currently dispatches and awaits Server Functions one at a time, if genuine parallel work is needed, perform it inside a single Server Function (or a Route Handler), rather than expecting several separate client-invoked calls to run concurrently.
Best Practices
- Verify authentication and authorization inside every Server Function, without exception, never rely on it only being reachable from an authenticated page.
- Revalidate before redirecting, never after.
- Model expected, anticipated failures as return values (via
useActionState), reservingthrowfor genuinely unexpected exceptions. - Rely on progressive enhancement for basic form submissions, it's a real, free benefit of the
<form action>mechanism, not something requiring extra work.
Performance Tips
- A Server Action's single round-trip (mutation + fresh UI together) is inherently more efficient than a separate mutate-then-refetch pair of requests, this is a structural advantage, not something you need to opt into.
updateTag(immediate) versusrevalidateTag(stale-while-revalidate) is a real performance/freshness tradeoff, reach forupdateTagspecifically for read-your-own-writes UX, andrevalidateTagwhen a slight, background-refreshed delay for other users is acceptable (see Caching).
