A Server Action is an async function marked with "use server" that can be called directly from a Client Component — or wired straight into a <form> — without you ever hand-writing a matching API route or a client-side fetch call to reach it:
// app/actions.ts
"use server";
export async function createComment(formData: FormData) {
const text = formData.get("text") as string;
await db.comment.create({ data: { text } });
revalidatePath("/comments");
}
// app/comments/CommentForm.tsx
import { createComment } from "../actions";
export default function CommentForm() {
return (
<form action={createComment}>
<input name="text" />
<button type="submit">Post</button>
</form>
);
}
What this actually replaces is the boilerplate loop nearly every app used to hand-write for a simple mutation: build a Route Handler that parses the request body, call it from the client with fetch, manage loading and error state around that fetch yourself, and keep the function signature on both ends in sync by hand. A Server Action collapses all of that into one function you call — or, as above, hand straight to a form's action prop — with Next.js generating and wiring up the actual network call for you.
They can look almost interchangeable for a simple mutation, but they solve genuinely different problems:
| Server Action | Route Handler | |
|---|---|---|
| Called from | Directly, like a function — from a form or from client code | Explicitly, via fetch("/api/...") |
| Consumers | Only your own app's components | Anything that can make an HTTP request — a mobile app, a webhook, a third party |
| Shape | An async function | A request/response handler, with full control over status codes, headers, content type |
| Works without JavaScript | Yes, via a plain <form action={...}> | No — the client has to actually run fetch |
Because a Server Action can be bound directly to a <form>'s action prop, that form keeps working even if the browser's JavaScript hasn't loaded yet, failed to load, or is disabled entirely — the browser falls back to submitting the form the way it always has, as a real HTTP request, and the Server Action still runs. Layer a Client Component around it with useFormStatus or useOptimistic and you get instant pending states and optimistic UI as a progressive enhancement on top of a form that was already functionally correct without any of that JavaScript — a mutation path that degrades gracefully instead of a blank, dead form the moment a script fails to load.
A Server Action compiles down to a real HTTP endpoint with its own URL, whether you ever call it that way or not. It looks like a plain function call from inside your component — createComment(formData) — but under the hood Next.js generates an actual network endpoint for it, and anyone who can see your client bundle can find that endpoint and send it a request directly, completely bypassing your form, your component, and any client-side checks you wrote around it.
"use server";
// WRONG — assumes the caller can only be your own authenticated UI,
// because that's the only place you *intended* it to be called from
export async function deletePost(id: string) {
await db.post.delete({ where: { id } });
}
// RIGHT — re-check authorization inside the action itself, every time,
// because the action is a public endpoint regardless of where you meant to expose it
export async function deletePost(id: string) {
const session = await getSession();
if (!session?.user || !(await canDeletePost(session.user.id, id))) {
throw new Error("Unauthorized");
}
await db.post.delete({ where: { id } });
}
Treat every Server Action exactly like you'd treat a public API route: validate its inputs, and re-check authentication and authorization inside the function body itself. A disabled delete button in your UI, or a form that only renders for admins, stops nothing on its own — those are UI conveniences, not a security boundary, and the actual security boundary has to live inside the action.