Topics
v5.0.0
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.

InvocationHowProgressive enhancement
Form action={fn}Native POST with FormDataWorks without JS
ImperativestartTransition(() => action(data))Requires hydration
Route HandlerManual 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 updates

Interview: 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 limits

Interview: 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 ActionsUse Route Handlers (route.ts)
Form mutations from your UIThird-party webhooks
Progressive enhancementREST API for external clients
Colocated with page logicFile uploads to blob storage
Revalidation after DB writeOAuth 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 URLs

File 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

Last updated on