On this page · sections
Server Actions & Mutations
Server Actions are async functions marked 'use server' that run on the server. They are invoked from forms or imperatively from Client Components — no manual API route needed for simple mutations.
| Invocation | How | Progressive enhancement |
|---|---|---|
Form action={fn} | Native POST with FormData | Works without JS |
| Imperative | startTransition(() => action(data)) | Requires hydration |
| Route Handler | Manual fetch to /api/* | Always requires JS |
Defining and invoking Server Actions
Definition
Mark a file or function with 'use server'. Next.js serializes calls over POST with action IDs.
// app/todos/actions.ts
"use server";
import { revalidatePath } from "next/cache";
export async function createTodo(formData: FormData) {
const title = String(formData.get("title") ?? "").trim();
if (!title) return { error: "Title required" };
await db.todo.create({ data: { title } });
revalidatePath("/todos");
return { ok: true };
}Form invocation (progressive enhancement):
"use client";
import { createTodo } from "./actions";
export function TodoForm() {
return (
<form action={createTodo}>
<input name="title" required />
<button type="submit">Add</button>
</form>
);
}Execution flow:
1. Form submits → POST with action ID + FormData
2. Next routes to server function
3. Server mutates DB
4. revalidatePath/Tag purges cache
5. RSC payload refreshes → UI updatesInterview: Server Actions eliminate boilerplate API routes for mutations. Native form submit works without JavaScript hydration.
useActionState and pending UI
Definition
React 19’s useActionState (formerly useFormState) wires action results and pending state to the UI:
"use client";
import { useActionState } from "react";
import { createTodo } from "./actions";
export function TodoForm() {
const [state, formAction, pending] = useActionState(createTodo, null);
return (
<form action={formAction}>
<input name="title" required />
<button type="submit" disabled={pending}>
{pending ? "Adding…" : "Add"}
</button>
{state?.error && <p role="alert">{state.error}</p>}
</form>
);
}Imperative invocation with useTransition:
"use client";
import { useTransition } from "react";
import { deleteTodo } from "./actions";
export function DeleteButton({ id }: { id: string }) {
const [pending, startTransition] = useTransition();
return (
<button
disabled={pending}
onClick={() => startTransition(() => deleteTodo(id))}
>
Delete
</button>
);
}Interview: Client Components import server actions — Next serializes a callable reference, not the function body.
Validation and error handling
Definition
Validate on the server always — FormData and client args are untrusted. Return structured errors for the UI; throw for unexpected failures.
"use server";
import { z } from "zod";
const CreateTodoSchema = z.object({
title: z.string().min(1).max(200),
});
export async function createTodo(formData: FormData) {
const parsed = CreateTodoSchema.safeParse({
title: formData.get("title"),
});
if (!parsed.success) {
return { error: parsed.error.flatten().fieldErrors.title?.[0] ?? "Invalid" };
}
try {
await db.todo.create({ data: { title: parsed.data.title } });
revalidatePath("/todos");
return { ok: true };
} catch {
return { error: "Could not save. Try again." };
}
}Redirect after success:
"use server";
import { redirect } from "next/navigation";
export async function createPost(formData: FormData) {
const post = await db.post.create({ data: { title: String(formData.get("title")) } });
revalidatePath("/posts");
redirect(`/posts/${post.id}`);
}Interview: Never trust FormData. Zod or similar on the server; return
{ error }for user-facing validation, throw for invariant violations.
Security and auth
Definition
Next.js injects action IDs and origin checks — POST from foreign origins is rejected. This is not authentication.
Always validate session inside the action:
"use server";
import { cookies } from "next/headers";
import { verifySession } from "@/lib/auth";
export async function deleteAccount() {
const session = await verifySession((await cookies()).get("session")?.value);
if (!session) throw new Error("Unauthorized");
await db.user.delete({ where: { id: session.userId } });
revalidatePath("/");
}Security checklist:
Validate session inside every action → action ID ≠ auth
Validate input server-side always → never trust FormData
Rate limit sensitive actions → payments, account deletion
Idempotency keys for payments → prevent double-charge
Validate file uploads server-side → MIME, size limitsInterview: Server Actions have built-in CSRF protection via origin checks, but you must still verify the user’s session inside the action.
Revalidation after mutations
Definition
Server Actions commonly call revalidatePath and revalidateTag after persisting changes:
"use server";
import { revalidatePath, revalidateTag } from "next/cache";
export async function updateProduct(id: string, formData: FormData) {
const session = await verifySession(/* ... */);
if (!session) return { error: "Unauthorized" };
await saveProduct(id, formData);
await revalidateTag(`product-${id}`);
revalidatePath(`/products/${id}`);
revalidatePath("/products");
return { ok: true };
}The RSC payload refreshes automatically after the action completes — no manual router.refresh() needed for form actions.
Real-world example
Optimistic UI with rollback on error:
"use client";
import { useOptimistic, useTransition } from "react";
import { toggleTodo } from "./actions";
type Todo = { id: string; done: boolean; title: string };
export function TodoList({ todos }: { todos: Todo[] }) {
const [optimistic, setOptimistic] = useOptimistic(todos);
const [, startTransition] = useTransition();
function handleToggle(id: string, done: boolean) {
startTransition(async () => {
setOptimistic((prev) =>
prev.map((t) => (t.id === id ? { ...t, done: !done } : t))
);
await toggleTodo(id);
});
}
return (
<ul>
{optimistic.map((t) => (
<li key={t.id}>
<input
type="checkbox"
checked={t.done}
onChange={() => handleToggle(t.id, t.done)}
/>
{t.title}
</li>
))}
</ul>
);
}Interview: Pair mutations with tag + path revalidation. Optimistic UI improves perceived speed; server action is the source of truth.
Server Actions vs Route Handlers
Definition
| Use Server Actions | Use Route Handlers (route.ts) |
|---|---|
| Form mutations from your UI | Third-party webhooks |
| Progressive enhancement | REST API for external clients |
| Colocated with page logic | File uploads to blob storage |
| Revalidation after DB write | OAuth callbacks |
// app/api/webhook/route.ts — external caller, not a form
export async function POST(request: Request) {
const payload = await request.json();
await processWebhook(payload);
return Response.json({ received: true });
}Server Actions accept serializable args only — FormData or plain objects. Not arbitrary functions or class instances.
Interview: Server Actions for internal mutations; Route Handlers for external HTTP contracts (webhooks, public APIs).
Common footguns
Definition
No server-side validation → trusting FormData blindly
Skipping auth in action → relying on action ID alone
Missing revalidation → UI shows stale data after mutation
Passing functions as args → not serializable — will fail
Large file uploads → validate size; consider signed URLsFile uploads are supported with size limits — validate MIME server-side. Large files may use a signed URL to blob storage instead.
Senior Interview Summary
Server Actions
Server-side mutations invoked from forms or Client Components. POST with action IDs; no manual API route for simple CRUD.
Security
Origin checks prevent CSRF, but session validation inside the action is mandatory.
Best mental model
Action → "Server function callable from the client boundary."
Form → "action={fn} works without JS."
Revalidate → "Purge cache after mutation, RSC refreshes."
Route Handler → "External HTTP contract, not internal mutation."Practical rule
Form mutation from your UI → Server Action
Webhook / public REST API → Route Handler
Always → validate session + input server-side
After write → revalidateTag + revalidatePath
Pending UI → useActionState or useTransition