Skip to content

Forms in Next.js

Forms are one of the most common places Server Actions get used in practice. This lesson covers building a complete form with pending states, validation error handling, and progressive enhancement.

A Complete Form Backed by a Server Action

Connecting a <form> directly to a Server Action via its action prop gives you a fully working submission flow with zero client-side JavaScript required for the basic case — the browser's native form submission behavior handles it, and Next.js progressively enhances it with a client-side transition when JavaScript is available.

For real applications, you also want validation error messages and a visible pending state while the action runs — React provides useActionState and useFormStatus specifically for this.

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

export async function createPost(prevState: unknown, formData: FormData) {
  const title = formData.get("title") as string;
  if (!title || title.length < 3) {
    return { error: "Title must be at least 3 characters." };
  }
  await db.post.create({ data: { title } });
  return { error: null };
}

Returning an object instead of throwing lets the form display validation errors without a full page reload.

useActionState for Validation Errors

"use client";
import { useActionState } from "react";
import { createPost } from "@/app/actions";

export default function NewPostForm() {
  const [state, formAction] = useActionState(createPost, { error: null });

  return (
    <form action={formAction}>
      <input name="title" />
      {state.error && <p>{state.error}</p>}
      <button type="submit">Create</button>
    </form>
  );
}
  • useActionState(action, initialState) wraps a Server Action to track its returned state across submissions.
  • The wrapped action receives the previous state as its first argument, and FormData as its second.
  • useFormStatus() (used inside a form's child component) reports whether the enclosing form is currently submitting.
  • Both hooks require the enclosing component to be a Client Component.

Forms Cheat Sheet

Hooks and patterns for building robust forms.

Hook / Pattern Purpose
<form action={serverAction}> Basic, progressively-enhanced submission
useActionState Track validation errors / returned state across submissions
useFormStatus Show a pending/loading state during submission
formData.get("field") Read a specific field's submitted value
Server-side validation Always re-validate on the server, even with client checks

Showing a Pending State with useFormStatus

useFormStatus must be called from a component rendered inside the <form>, not the form's own component, since it reads status from the nearest parent form context.

"use client";
import { useFormStatus } from "react-dom";

function SubmitButton() {
  const { pending } = useFormStatus();
  return <button type="submit" disabled={pending}>
    {pending ? "Creating..." : "Create Post"}
  </button>;
}

SubmitButton must be rendered as a child of the <form> using the Server Action.

Why This Works Without JavaScript

Because <form action={serverAction}> uses the browser's native form submission mechanism under the hood, the form still works — with a full page navigation instead of a smooth client-side transition — even if JavaScript fails to load. This is a meaningful accessibility and resilience improvement over purely client-side form handling.

Common Mistakes

  • Only validating on the client and skipping server-side validation inside the Server Action itself.
  • Calling useFormStatus() in the same component that renders the <form>, instead of a child component.
  • Forgetting useActionState's action receives (previousState, formData), not just formData.
  • Not disabling the submit button while pending is true, allowing duplicate submissions.

Key Takeaways

  • Server Actions connect directly to <form action={...}> for progressively-enhanced submissions.
  • useActionState tracks validation errors and other returned state across submissions.
  • useFormStatus shows a pending state, but only from a component nested inside the form.
  • Always validate submitted data on the server, regardless of any client-side checks.
  • Form submissions still work even without JavaScript, thanks to native form semantics.

Pro Tip

Design your Server Action to always return a consistent shape (like { error: string | null }) even on success, rather than sometimes returning undefined — this makes the client-side state handling in useActionState predictable and avoids extra if checks scattered through your form component.