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 .