A server action is a function you can call from a form
No route, no fetch, no JSON. The framework creates the endpoint, posts the form to it, runs the function on the server and re-renders what changed. And because it is a real <form> with a real action, it works before any JavaScript has loaded.
"use server";
export async function createDoor(formData) {
const name = formData.get("name");
await db.doors.insert({ name });
revalidatePath("/doors");
}
// app/doors/page.tsx — a server component
import { createDoor } from "./actions";
<form action={createDoor}>
<input name="name" />
<button>Add</button>
</form>
The directive, in two places
"use server"; // every export in this file is an action
// or inside a server component, for one action
export default async function Page() {
async function remove(formData) {
"use server";
await db.doors.delete(formData.get("id"));
revalidatePath("/doors");
}
return <form action={remove}>…</form>;
}
It does not mean "this is a server component" — everything already is. It means "this function may be called by the browser", which makes it a public endpoint. Every action must validate its input and check who is calling, exactly like a route you wrote by hand.
Reporting back to the user
pendingis given to you — nouseStatefor a loading flag.- Whatever the action returns becomes
state, so validation errors are just a return value. useFormStatus()gives a nested submit button the samepending, without threading a prop.
"use client";
import { useActionState } from "react";
export function AddDoor({ action }) {
const [state, formAction, pending] = useActionState(action, { errors: {} });
return (
<form action={formAction}>
<input name="name" aria-invalid={!!state.errors.name} />
{state.errors.name && <small role="alert">{state.errors.name}</small>}
<button disabled={pending}>{pending ? "Adding…" : "Add"}</button>
</form>
);
}
// and the action takes the previous state as its first argument
export async function createDoor(prevState, formData) { … return { errors }; }
Optimistic updates
The temporary item is discarded automatically when the action finishes and the server data arrives. That is the whole feature: you do not write the rollback.
"use client";
const [shown, addOptimistic] = useOptimistic(doors, (state, door) => [...state, { ...door, pending: true }]);
async function onSubmit(formData) {
addOptimistic({ id: "temp", name: formData.get("name") });
await createDoor(formData); // if it fails, React puts the real list back
}
After the write: say what changed
Forgetting this is the most common bug with actions: the write succeeds, the cache does not know, and the list looks unchanged until a hard refresh.
revalidatePath("/doors"); // this route
revalidateTag("doors"); // every fetch tagged "doors"
redirect("/doors/" + door.id); // after creating, go to it
Security, because it is an endpoint
A client cannot see the action’s code, but it can call it with anything. "It is only called from my form" is not a control.
"use server";
export async function deleteDoor(formData) {
const session = await auth(); // 1. who is this?
if (!session) throw new Error("Not signed in");
const id = String(formData.get("id"));
const door = await db.doors.byId(id);
if (door.ownerId !== session.userId) throw new Error("Not yours"); // 2. may they?
const parsed = Schema.safeParse({ id }); // 3. is the input sane?
if (!parsed.success) return { errors: … };
await db.doors.delete(id);
revalidateTag("doors");
}
Action or route handler?
| Use an action | Use a route handler |
|---|---|
| A form in your own app | A public API |
| A mutation followed by a re-render | Something a mobile app or a webhook calls |
| You want progressive enhancement | You need the URL, the method and the headers |
| The result is UI | The result is data for someone else |