D
Dev SOPKnowledge Base
Search
← All topics

Next.js Server Actions: Form Handling, Validation, and Mutations

Next.js Server Actions for form handling — Zod validation, useActionState hook, optimistic updates, revalidation, and error handling patterns.

nextjsserver-actionsformszodreactfull-stack
Agent trigger phrases: server actions · use server · useActionState · form action · server mutation · revalidatePath · Next.js form

Overview

Server Actions are async functions that run on the server, triggered directly from client components. They replace API route handlers for mutations in Next.js applications.

Basic Server Action

// app/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";

const CreatePostSchema = z.object({
  title: z.string().min(1, "Title is required").max(200),
  content: z.string().min(10, "Content must be at least 10 characters"),
  tags: z.string().transform(s => s.split(",").map(t => t.trim()).filter(Boolean)),
});

export async function createPost(formData: FormData) {
  const parsed = CreatePostSchema.safeParse(Object.fromEntries(formData));
  if (!parsed.success) {
    return { error: parsed.error.flatten() };
  }

  await db.post.create({ data: parsed.data });
  revalidatePath("/posts");
  return { success: true };
}

Form with useActionState

// app/posts/new/page.tsx
"use client";
import { useActionState } from "react";
import { createPost } from "../actions";

type State = { error?: { fieldErrors: Record<string, string[]> } } | { success: true } | null;

export default function NewPost() {
  const [state, action, isPending] = useActionState<State, FormData>(createPost, null);

  return (
    <form action={action}>
      <div>
        <label htmlFor="title">Title</label>
        <input id="title" name="title" required />
        {state?.error?.fieldErrors.title && (
          <p className="error">{state.error.fieldErrors.title[0]}</p>
        )}
      </div>
      <div>
        <label htmlFor="content">Content</label>
        <textarea id="content" name="content" required />
        {state?.error?.fieldErrors.content && (
          <p className="error">{state.error.fieldErrors.content[0]}</p>
        )}
      </div>
      <button type="submit" disabled={isPending}>
        {isPending ? "Creating..." : "Create Post"}
      </button>
    </form>
  );
}

Optimistic Updates

"use client";
import { useOptimistic } from "react";
import { toggleLike } from "./actions";

function PostCard({ post, userId }: { post: Post; userId: string }) {
  const [optimisticLikes, addOptimistic] = useOptimistic(
    post.likes,
    (state, liked: boolean) =>
      liked
        ? [...state, { userId }]
        : state.filter(l => l.userId !== userId)
  );

  const isLiked = optimisticLikes.some(l => l.userId === userId);

  async function handleLike() {
    addOptimistic(!isLiked);    // Instant UI update
    await toggleLike(post.id);  // Server action (actual save)
  }

  return (
    <button onClick={handleLike}>
      {isLiked ? "Unlike" : "Like"} ({optimisticLikes.length})
    </button>
  );
}

Authentication Check

"use server";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";

export async function deletePost(postId: string) {
  const session = await auth();
  if (!session?.user) {
    redirect("/login");
  }

  // Check ownership
  const post = await db.post.findUnique({ where: { id: postId } });
  if (post?.authorId !== session.user.id) {
    throw new Error("Unauthorized");
  }

  await db.post.delete({ where: { id: postId } });
  revalidatePath("/posts");
}

Revalidation Strategies

"use server";
import { revalidatePath, revalidateTag } from "next/cache";

// Revalidate a specific route
revalidatePath("/posts");
revalidatePath("/posts/[slug]", "page");  // Dynamic route

// Revalidate all routes with a layout
revalidatePath("/dashboard", "layout");

// Revalidate by cache tag
revalidateTag("posts");        // Any fetch() tagged with "posts"
revalidateTag(`post-${id}`);   // Specific post
// Tag fetches for targeted revalidation
const data = await fetch("https://api.example.com/posts", {
  next: { tags: ["posts"] }
});

File Upload Action

"use server";
import { put } from "@vercel/blob";

export async function uploadFile(formData: FormData) {
  const file = formData.get("file") as File;
  if (!file || file.size === 0) {
    return { error: "No file provided" };
  }

  // Validate type and size
  if (!file.type.startsWith("image/")) {
    return { error: "Only images allowed" };
  }
  if (file.size > 5 * 1024 * 1024) {
    return { error: "Max 5MB" };
  }

  const blob = await put(file.name, file, { access: "public" });
  return { url: blob.url };
}

Common Patterns

// Inline server action (for one-off use)
export default function Page() {
  async function handleDelete(formData: FormData) {
    "use server";
    const id = formData.get("id") as string;
    await db.item.delete({ where: { id } });
    revalidatePath("/items");
  }

  return (
    <form action={handleDelete}>
      <input type="hidden" name="id" value="123" />
      <button type="submit">Delete</button>
    </form>
  );
}

// Programmatic invocation from client
const handleClick = () => startTransition(() => createPost(new FormData()));

Server Actions vs API Routes

| Use Case | Server Action | API Route | |----------|--------------|-----------| | Form submission | Yes — simpler DX | Overkill | | Mutation from client button | Yes | Works but verbose | | External webhook receiver | No | Yes | | Third-party API consumption (no auth) | No | Yes | | File upload (small) | Yes | Works | | Streaming response | No | Yes |