~/TechPurAI
~/tutorials/nextjs-from-scratch/building-a-comment-form
intermediate·part 15 of 22·3 min read

Building a comment form with a Server Action

Updated Aug 31, 2026JavaScript · Next.js

Part 14's likePost action needed no feedback beyond "it happened." A comment form needs both: validation errors when something's wrong, and a pending state while it's submitting — this part builds both properly.

The Server Action, with validation

ts
// app/actions.ts
"use server";

import { revalidatePath } from "next/cache";

export interface CommentState {
  error?: string;
  success?: boolean;
}

export async function submitComment(
  slug: string,
  _prevState: CommentState,
  formData: FormData
): Promise<CommentState> {
  const body = formData.get("body");

  if (typeof body !== "string" || body.trim().length < 3) {
    return { error: "Comment needs at least 3 characters." };
  }

  // a real implementation would save this to a database
  console.log(`New comment on ${slug}: ${body}`);
  revalidatePath(`/posts/${slug}`);
  return { success: true };
}

This Server Action takes two extra parameters beyond a plain form action: the previous state, and formData — the shape useActionState (next) expects specifically. Returning { error: "..." } instead of throwing is deliberate: a validation failure is expected, recoverable input from a real person, not an exceptional case.

Wiring it up with useActionState

tsx
// app/components/CommentForm.tsx
"use client";

import { useActionState } from "react";
import { submitComment, type CommentState } from "@/app/actions";

export function CommentForm({ slug }: { slug: string }) {
  const action = submitComment.bind(null, slug);
  const [state, formAction] = useActionState<CommentState, FormData>(action, {});

  return (
    <form action={formAction}>
      <textarea name="body" placeholder="Leave a comment" required />
      {state.error && <p role="alert">{state.error}</p>}
      {state.success && <p>Comment posted.</p>}
      <SubmitButton />
    </form>
  );
}

submitComment.bind(null, slug) pre-fills the action's first argument (slug) ahead of time, since useActionState itself controls the remaining two — useActionState wires the action to the form, tracks its returned state across submissions, and re-renders the component with whatever the action last returned, state.error or state.success here.

A pending state with useFormStatus

tsx
// app/components/CommentForm.tsx
import { useFormStatus } from "react-dom";

function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? "Posting…" : "Post comment"}
    </button>
  );
}

useFormStatus only works inside a component rendered within the <form> it's reporting on — this is exactly why SubmitButton is a separate component nested inside CommentForm's <form>, rather than reading pending directly in CommentForm itself, which has no enclosing form of its own to report on.

Common mistake

Calling useFormStatus in the same component that renders the <form> tag, expecting it to reflect that form's own pending state. It only reports on an ancestor form — a component needs to be a descendant of the <form>, not the one rendering it, which is why the pattern above always splits the submit button into its own nested component.

FAQ

Why use a Server Action instead of a Next.js API route for this? Both can validate and save a comment. A Server Action skips writing a separate API route entirely — the form's action prop calls it directly, with no manual fetch call or JSON serialization needed on the client. An API route is still the right choice for an endpoint meant to be called from outside this app (a mobile client, a webhook), which a Server Action isn't designed for.

Does revalidatePath refresh the page immediately for the person who just commented? Yes — it invalidates the cached data for that path server-side, and because the action's result flows back through useActionState, the component re-renders with fresh data on the same round trip, without a manual page reload.

Could I show the new comment immediately, before the server confirms it saved? Yes — that's what useOptimistic (a related hook, not used in this specific form) is for: rendering an assumed-successful result immediately while the action is still in flight, then reconciling with the real result once it resolves. This form waits for the real result instead, which is simpler and appropriate for a comment that needs validation before it's confirmed.

Next: environment variables — this comment form's real database connection, and anything else that shouldn't be hardcoded or committed to git.

VK

Vijay Kumar

Founder of TechPurAI — writing hands-on tutorials and honest tool breakdowns.

LinkedIn ↗
← previous14. Server Actions: mutating data without an API routenext →16. Environment variables and secrets