AI Reading
Quick summary of this article
Build production forms in Next.js 16 with Server Actions: progressive enhancement, useActionState, Zod schema validation, field-level errors and optimistic UI.
- Server Actions are async functions marked
"use server"that run on the server and can be called directly from a form. useActionStategives you pending state and returned validation errors without any fetch code.- Validate on the server with a Zod schema — client-side validation is a convenience, never a security boundary.
useOptimisticrenders the expected result instantly while the action is still in flight.
Complete Next.js 16 Series — Module 7 of 12
Forms are where the App Router pays off most. There is no API route, no fetch, no useState for loading, no manual revalidation. A <form> posts directly to a server function, and the page re-renders with fresh data.
Your First Server Action
// src/app/contact/page.tsx
export default function ContactPage() {
async function submit(formData: FormData) {
"use server";
const name = String(formData.get("name") ?? "");
const message = String(formData.get("message") ?? "");
await db.message.create({ data: { name, message } });
}
return (
<form action={submit} className="mx-auto max-w-md space-y-4 px-6 py-12">
<input name="name" required className="w-full rounded border px-3 py-2" />
<textarea name="message" required className="w-full rounded border px-3 py-2" />
<button type="submit" className="rounded bg-brand-600 px-4 py-2 text-white">
Send
</button>
</form>
);
}
Two rules make this work:
- The function is
asyncand starts with"use server". - The
<form>usesaction={fn}, notonSubmit.
Because it is a real form submission, it works without JavaScript. Next.js enhances it with client-side handling when JS is available, but the baseline is a normal POST — progressive enhancement for free.
Inline
"use server"functions inside a component are convenient but re-created per render. For anything reusable, put actions in a separate file with"use server"at the top.
Validating with Zod
Install Zod:
npm install zod
Define the schema once and derive the TypeScript type from it:
// src/lib/schemas.ts
import { z } from "zod";
export const contactSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters").max(80),
email: z.email("Enter a valid email address"),
message: z.string().min(10, "Message must be at least 10 characters").max(2000),
});
export type ContactInput = z.infer<typeof contactSchema>;
Now the action validates before touching the database:
// src/app/contact/actions.ts
"use server";
import { contactSchema } from "@/lib/schemas";
import { z } from "zod";
import { revalidateTag } from "next/cache";
export type ActionState = {
ok: boolean;
message: string;
errors?: Record<string, string[]>;
};
export async function submitContact(
_prev: ActionState,
formData: FormData
): Promise<ActionState> {
const parsed = contactSchema.safeParse({
name: formData.get("name"),
email: formData.get("email"),
message: formData.get("message"),
});
if (!parsed.success) {
return {
ok: false,
message: "Please fix the errors below.",
errors: z.flattenError(parsed.error).fieldErrors,
};
}
await db.message.create({ data: parsed.data });
revalidateTag("messages", "max");
return { ok: true, message: "Thanks — your message was sent." };
}
z.flattenError() collapses a ZodError into { formErrors, fieldErrors }, which maps directly onto your form UI in Zod 4.
useActionState for Errors and Pending State
"use client";
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
import { submitContact, type ActionState } from "./actions";
const initialState: ActionState = { ok: false, message: "" };
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="rounded bg-brand-600 px-4 py-2 text-white disabled:opacity-50"
>
{pending ? "Sending…" : "Send"}
</button>
);
}
export default function ContactForm() {
const [state, formAction] = useActionState(submitContact, initialState);
return (
<form action={formAction} className="mx-auto max-w-md space-y-4 px-6 py-12">
<div>
<input
name="name"
aria-invalid={!!state.errors?.name}
className="w-full rounded border px-3 py-2"
/>
{state.errors?.name && (
<p className="mt-1 text-sm text-red-600">{state.errors.name[0]}</p>
)}
</div>
<div>
<input name="email" type="email" className="w-full rounded border px-3 py-2" />
{state.errors?.email && (
<p className="mt-1 text-sm text-red-600">{state.errors.email[0]}</p>
)}
</div>
<div>
<textarea name="message" rows={5} className="w-full rounded border px-3 py-2" />
{state.errors?.message && (
<p className="mt-1 text-sm text-red-600">{state.errors.message[0]}</p>
)}
</div>
{state.message && (
<p className={state.ok ? "text-sm text-green-700" : "text-sm text-red-600"}>
{state.message}
</p>
)}
<SubmitButton />
</form>
);
}
useFormStatus must be called from a component inside the form — that is a frequent gotcha.
Revalidating After a Mutation
Three tools, three situations:
import { revalidateTag, revalidatePath, updateTag } from "next/cache";
// Invalidate a tagged cache entry (stale-while-revalidate)
revalidateTag("posts", "max");
// Invalidate a whole route path
revalidatePath("/dashboard/products");
// Read-your-writes inside a Server Action
updateTag("posts");
If a list and its detail pages share a tag, one revalidateTag refreshes both.
Optimistic UI with useOptimistic
For actions that feel instant — likes, checklist toggles, cart updates — render the expected result immediately:
"use client";
import { useOptimistic, useTransition } from "react";
import { toggleTodo } from "./actions";
type Todo = { id: string; title: string; done: boolean };
export function TodoList({ todos }: { todos: Todo[] }) {
const [isPending, startTransition] = useTransition();
const [optimistic, applyOptimistic] = useOptimistic(
todos,
(state: Todo[], toggledId: string) =>
state.map((t) => (t.id === toggledId ? { ...t, done: !t.done } : t))
);
function onToggle(id: string) {
startTransition(async () => {
applyOptimistic(id);
await toggleTodo(id); // real Server Action
});
}
return (
<ul className={isPending ? "opacity-70" : ""}>
{optimistic.map((t) => (
<li key={t.id}>
<label className="flex items-center gap-3">
<input type="checkbox" checked={t.done} onChange={() => onToggle(t.id)} />
<span className={t.done ? "line-through" : ""}>{t.title}</span>
</label>
</li>
))}
</ul>
);
}
The checkbox flips in the same frame as the click. If the server call fails, React discards the optimistic state and the list reverts — no manual rollback code.
Server Actions in Forms Without useActionState
Sometimes all you need is a button:
export function DeleteButton({ id }: { id: string }) {
async function remove() {
"use server";
await db.product.delete({ where: { id } });
revalidateTag("products", "max");
}
return (
<form action={remove}>
<button type="submit" className="text-sm text-red-600 hover:underline">
Delete
</button>
</form>
);
}
Delete operations must be forms, not onClick handlers, if you want them to work without JavaScript.
Security Rules for Server Actions
Server Actions are public HTTP endpoints. Treat every argument as untrusted.
- Always authorise. An action has no implicit auth. Check the session at the top of every mutating action.
- Always validate with a schema — never trust the client’s
requiredattributes. - Never trust hidden IDs for ownership. Verify
resource.userId === session.user.id. - Use
redirect()carefully — it throws, so call it outsidetry/catch.
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
const product = await db.product.findUnique({ where: { id } });
if (product?.ownerId !== session.user.id) throw new Error("Forbidden");
Module 7 Recap
- Server Actions are async server functions callable from
<form action={...}>, and they work without JavaScript. useActionStatereturns[state, formAction]for validation errors and results.useFormStatusprovidespendinginside the form tree.- Validate with Zod and map
z.flattenError()to field-level messages. useOptimistic+useTransitiondeliver instant perceived performance.- Authorise and validate inside every action — the client is not a security boundary.
Next up — Module 8: Route Handlers — building REST endpoints, calling third-party APIs and handling webhooks.
Continue the Course
- Course index: Next.js 16 Tutorial – all 12 modules
- Previous: Module 6: Data Fetching & Caching
- Next: Module 8: Route Handlers & APIs
