intermediate10 min readPart 3 of 3

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.

TechLogHub
·

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

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:

  • Authenticate — check the session, not the UI (hiding the button protects nothing).
  • Validate — parse inputs with a schema before touching the database.
  • Authorize — verify this user may mutate this record.
  • "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

  • One actions.ts per feature; every action validates + authenticates first.
  • Forms + useActionState for anything with fields; plain action calls for one-click mutations.
  • Always end mutations with revalidatePath/revalidateTag — or return the new data and let optimistic state carry the moment.
  • Server Actions run sequentially per user — keep them fast; queue heavy work.
  • 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.

    nextjsserver-actionsformsreactvalidation

    Keep the references coming

    One email a week: new and trending developer tools, fresh comparisons, and what shipped. Unsubscribe in one click.