Forms cheatsheet.
Plain form + action
// app/posts/new/page.tsx
import { createPost } from "../actions";
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" required />
<textarea name="body" required />
<button>Create</button>
</form>
);
}
// app/posts/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
export async function createPost(formData: FormData) {
await db.post.create({
data: {
title: formData.get("title") as string,
body: formData.get("body") as string,
},
});
revalidatePath("/posts");
redirect("/posts");
}
Works without JS. Faster after JS loads.
Zod validation
"use server";
import { z } from "zod";
const Schema = z.object({
title: z.string().min(1, "title required").max(200),
body: z.string().min(10, "body too short"),
});
export type FormState =
| { ok: true }
| { ok: false; errors: Record<string, string[]> };
export async function createPost(prev: FormState, formData: FormData): Promise<FormState> {
const parsed = Schema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return { ok: false, errors: parsed.error.flatten().fieldErrors };
}
await db.post.create({ data: parsed.data });
revalidatePath("/posts");
return { ok: true };
}
useActionState
"use client";
import { useActionState } from "react";
import { createPost, type FormState } from "./actions";
const initial: FormState = { ok: true };
export function PostForm() {
const [state, formAction, pending] = useActionState(createPost, initial);
return (
<form action={formAction}>
<input name="title" />
{!state.ok && state.errors.title && (
<p className="text-red-500">{state.errors.title[0]}</p>
)}
<textarea name="body" />
{!state.ok && state.errors.body && (
<p className="text-red-500">{state.errors.body[0]}</p>
)}
<button disabled={pending}>{pending ? "Saving…" : "Save"}</button>
</form>
);
}
Pending state per-field
"use client";
import { useFormStatus } from "react-dom";
export function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? "..." : "Save"}</button>;
}
Put it inside the <form>.
File uploads
<form action={uploadAction} encType="multipart/form-data">
<input type="file" name="image" />
<button>Upload</button>
</form>
"use server";
export async function uploadAction(formData: FormData) {
const file = formData.get("image") as File;
const buf = Buffer.from(await file.arrayBuffer());
await fs.writeFile(`./uploads/${file.name}`, buf);
}
For S3/R2, upload via signed URL from client to avoid Vercel function payload limits.
React Hook Form + server action
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createPost } from "./actions";
const Schema = z.object({ title: z.string().min(1), body: z.string().min(10) });
type Form = z.infer<typeof Schema>;
export function PostForm() {
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<Form>({
resolver: zodResolver(Schema),
});
async function onSubmit(data: Form) {
const fd = new FormData();
Object.entries(data).forEach(([k, v]) => fd.append(k, v));
await createPost(fd);
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("title")} />
{errors.title && <p>{errors.title.message}</p>}
<textarea {...register("body")} />
{errors.body && <p>{errors.body.message}</p>}
<button disabled={isSubmitting}>Save</button>
</form>
);
}
Best of both: client-side validation feedback + server-side trust.
Bound action arguments
async function deletePost(id: number) {
"use server";
await db.post.delete({ where: { id } });
revalidatePath("/posts");
}
<form action={deletePost.bind(null, post.id)}>
<button>Delete</button>
</form>
Optimistic updates
"use client";
import { useOptimistic } from "react";
export function Likes({ initial }: { initial: number }) {
const [optimistic, addOptimistic] = useOptimistic(initial, (s) => s + 1);
async function like() {
addOptimistic(1);
await likeAction();
}
return <button onClick={like}>{optimistic}</button>;
}
Re-using action across forms
const updateNameAction = updateUserAction.bind(null, "name");
const updateEmailAction = updateUserAction.bind(null, "email");
<form action={updateNameAction}>...</form>
<form action={updateEmailAction}>...</form>
Common mistakes
- Forgetting
"use server"directive. - No Zod / validation — accepts anything.
- Not revalidating after mutation.
- Catching
redirect()— never do; it’s meant to throw. - Putting client-only logic (window, etc) inside action.
Read this next
If you want my forms + Zod + server actions starter, it’s at rajpoot.dev .
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .