Next.js Server Actions: Forms, Validation & Optimistic UI
Handle mutations the App Router way: Server Actions with 'use server', progressive-enhancement forms, useActionState for validation errors, and useOptimistic for instant UI.
Prerequisites
- •Parts 1–2 of this series
- •Basic HTML forms knowledge
What You'll Learn
- Write and secure Server Actions with 'use server'
- Build forms that work before JavaScript loads
- Return validation errors with useActionState
- Add instant feedback with useOptimistic and revalidate correctly
Next.js App Router Essentials
Mutations Without API Routes
Reading data was Parts 1–2. Writing it used to mean building API routes plus fetch calls plus loading-state plumbing. Server Actions collapse that: a function marked "use server" runs on the server but can be called from your components — including directly as a form's action.
The Basic Shape
// app/guestbook/actions.ts
"use server";import { revalidatePath } from "next/cache";
export async function addEntry(formData: FormData) {
const message = String(formData.get("message") ?? "").trim();
if (!message) return;
await db.entry.create({ data: { message } });
revalidatePath("/guestbook");
}
// app/guestbook/page.tsx (server component)
import { addEntry } from "./actions";export default async function GuestbookPage() {
const entries = await db.entry.findMany({ orderBy: { id: "desc" } });
return (
<>
<form action={addEntry}>
<input name="message" placeholder="Say hi" />
<button type="submit">Sign</button>
</form>
<ul>{entries.map((e) => <li key={e.id}>{e.message}</li>)}</ul>
</>
);
}
Notice what's absent: no /api route, no onSubmit, no JSON serialization, no client JavaScript at all. This form works before hydration — submit it with JS disabled and it still functions, then upgrades to a smooth no-reload submission once React loads. The revalidatePath call ties into Part 2: mutate, then invalidate, and the page re-renders with fresh data.
Treat Actions Like Public Endpoints
This is the security rule that matters: every Server Action is a reachable HTTP endpoint. The function feels local, but anyone can invoke it with crafted input. So, inside every action:
"use server";
import { z } from "zod";const schema = z.object({ message: z.string().min(1).max(280) });
export async function addEntry(formData: FormData) {
const user = await getSession();
if (!user) throw new Error("Unauthorized");
const parsed = schema.safeParse({ message: formData.get("message") });
if (!parsed.success) throw new Error("Invalid input");
await db.entry.create({ data: { message: parsed.data.message, userId: user.id } });
revalidatePath("/guestbook");
}
Validation Errors Users Can See: useActionState
Throwing on bad input is crude — users deserve field errors. useActionState gives your form a state channel to the action:
// actions.ts
"use server";export type FormState = { error?: string; success?: boolean };
export async function addEntry(_prev: FormState, formData: FormData): Promise<FormState> {
const parsed = schema.safeParse({ message: formData.get("message") });
if (!parsed.success) {
return { error: parsed.error.issues[0].message };
}
await db.entry.create({ data: parsed.data });
revalidatePath("/guestbook");
return { success: true };
}
// form.tsx
"use client";
import { useActionState } from "react";
import { addEntry, type FormState } from "./actions";export function GuestbookForm() {
const [state, formAction, pending] = useActionState<FormState, FormData>(
addEntry,
{},
);
return (
<form action={formAction}>
<input name="message" aria-invalid={!!state.error} />
{state.error && <p className="text-red-400 text-sm">{state.error}</p>}
<button disabled={pending}>{pending ? "Signing…" : "Sign"}</button>
</form>
);
}
The action now returns state instead of throwing; pending covers the in-flight UI. (useFormStatus offers the same pending flag to deeply nested submit buttons.)
Instant Feedback: useOptimistic
For interactions that should feel immediate — likes, upvotes, toggles — render the expected result now and reconcile when the server confirms:
"use client";
import { useOptimistic, startTransition } from "react";
import { toggleLike } from "./actions";export function LikeButton({ liked, count }: { liked: boolean; count: number }) {
const [view, applyOptimistic] = useOptimistic(
{ liked, count },
(cur, next: boolean) => ({ liked: next, count: cur.count + (next ? 1 : -1) }),
);
return (
<button
onClick={() =>
startTransition(async () => {
applyOptimistic(!view.liked);
await toggleLike(!view.liked); // server action
})
}
>
{view.liked ? "♥" : "♡"} {view.count}
</button>
);
}
If the action fails, React rolls the optimistic state back to the server truth automatically on the next render — you get instant UI with correctness preserved.
Rules of Thumb
actions.ts per feature; every action validates + authenticates first.useActionState for anything with fields; plain action calls for one-click mutations.revalidatePath/revalidateTag — or return the new data and let optimistic state carry the moment.Series Wrap-Up
You now hold the full App Router loop: structure and rendering (Part 1), reading data with the right cache semantics (Part 2), and writing it back with progressively-enhanced, validated mutations (this part). That trio — layouts, caching, actions — is 90% of every production Next.js app; the rest is your product.

