Loading…
Loading…
Handle forms in Next.js with Server Actions using useActionState, progressive enhancement, validation with Zod, and proper error handling.
Tags
This is part of the AI Automation Engineer Roadmap series.
Use Server Actions with useActionState to handle form submissions directly on the server with validation, error handling, and progressive enhancement built in.
Handling forms in Next.js traditionally requires creating API routes, writing fetch calls, managing loading state, and handling errors manually. For simple mutations like creating a post or updating a profile, this boilerplate adds up. Worse, forms break entirely when JavaScript fails to load.
Server Actions matter because forms are one of the most common mutation flows in an app:
If your form architecture is noisy, every one of those flows becomes harder to build and maintain. Server Actions simplify the mutation path by letting the form post directly to a server function instead of bouncing through a hand-written API route.
The key shift is that the form submits to a server function, not to a client-side fetch handler.
That gives you a cleaner flow:
useActionStateThis is simpler than:
// app/actions.ts
"use server";
import { z } from "zod";
const ContactSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
message: z.string().min(10),
});
export async function submitContact(prevState: any, formData: FormData) {
const raw = Object.fromEntries(formData);
const result = ContactSchema.safeParse(raw);
if (!result.success) {
return {
errors: result.error.flatten().fieldErrors,
message: null,
};
}
await db.contact.create({ data: result.data });
return { errors: null, message: "Message sent successfully!" };
}"use client";
import { useActionState } from "react";
import { submitContact } from "./actions";
export function ContactForm() {
const [state, formAction, isPending] = useActionState(submitContact, {
errors: null,
message: null,
});
return (
<form action={formAction}>
<input name="name" placeholder="Your name" />
{state.errors?.name && <p className="text-red-500">{state.errors.name}</p>}
<input name="email" type="email" placeholder="Email" />
{state.errors?.email && <p className="text-red-500">{state.errors.email}</p>}
<textarea name="message" placeholder="Message" />
{state.errors?.message && <p className="text-red-500">{state.errors.message}</p>}
<button type="submit" disabled={isPending}>
{isPending ? "Sending..." : "Send Message"}
</button>
{state.message && <p className="text-green-500">{state.message}</p>}
</form>
);
}No API route, no fetch call, no manual state management.
The return shape from your action becomes part of your UI contract. Keep it predictable.
A practical pattern is:
type ActionState = {
errors: Record<string, string[]> | null;
message: string | null;
};That makes it easy to display:
useActionStateIf your return shape changes arbitrarily between forms, the UI layer gets messy fast.
FormData on the ServerValidation should happen at the trust boundary, which is the Server Action itself.
const raw = Object.fromEntries(formData);
const result = ContactSchema.safeParse(raw);This matters even if you also validate on the client. Client-side validation improves UX. Server-side validation protects the mutation.
For small forms, keep the Zod schema near the action. For larger apps, extract it into a shared validation module so your server action, route handlers, and tests all use the same schema.
Form values arrive as strings. That means checkboxes, numbers, and dates usually need explicit parsing or coercion. A lot of subtle bugs come from skipping this step.
One of the strongest reasons to use Server Actions for forms is that they preserve native form behavior.
When the form uses the action prop:
That is not just a progressive enhancement talking point. It reduces the number of moving parts in a critical user flow.
Many forms do not just return a message. They create or update data, then need to refresh cached views or redirect.
Common server-side follow-ups:
revalidatePath() after mutationsredirect() after successful submissionsimport { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
export async function createPost(prevState: any, formData: FormData) {
const raw = Object.fromEntries(formData);
const result = PostSchema.safeParse(raw);
if (!result.success) {
return {
errors: result.error.flatten().fieldErrors,
message: null,
};
}
const post = await db.post.create({ data: result.data });
revalidatePath("/dashboard/posts");
redirect(`/dashboard/posts/${post.id}`);
}This is one of the biggest ergonomic wins over the older API-route-plus-fetch pattern.
Server Actions do not automatically make a mutation safe. You still need to verify:
That logic belongs in the action or in the service layer the action calls.
The server function is exactly where validation should happen. Skipping it just because the form "already validated in the browser" is a bad trade.
If one action returns strings, another returns nested objects, and a third throws raw errors, your form components become harder to reuse.
The client form component should mostly render fields and state. Validation, persistence, permissions, and side effects belong on the server.
If your form only works because of extra client logic layered on top, you are giving up one of the best parts of Server Actions.
If you are using Server Actions for real app forms, a good baseline is:
useActionState for field and form messagesThat pattern works well for most App Router forms without falling back to an API route for every mutation.
Server Actions are async functions that execute on the server when the form is submitted. The action prop on <form> uses native HTML form submission, so the form works even without JavaScript (progressive enhancement). useActionState manages the form state lifecycle, providing the previous return value and a pending flag. Zod validation runs server-side, ensuring no invalid data reaches your database regardless of client-side manipulation.
Server Actions are the right default for many Next.js form mutations. They reduce boilerplate, preserve progressive enhancement, and keep validation at the trust boundary. Use them when you want a cleaner server-first form flow, not just a different syntax for the same old pattern.
Collaboration
I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI: let's bring your vision to life.
Five essential built-in generic utility types in TypeScript that will save you hundreds of lines of code.
Generate dynamic Open Graph images in Next.js using the ImageResponse API with custom fonts, gradients, and data-driven content for social sharing.
Create reusable GitHub Actions workflows with inputs, secrets, and outputs to eliminate YAML duplication across repositories and teams efficiently.