Skip to content
Codeloom
Next.js

Next.js Server Actions: Forms and Mutations Guide

Learn how to use Next.js Server Actions for form handling, data mutations, optimistic updates, and error handling with practical examples.

·10 min read · By Codeloom
Intermediate 12 min read

What you'll learn

  • How to define and invoke Server Actions in Next.js
  • Building progressive forms that work without JavaScript
  • Implementing optimistic updates for instant feedback
  • Handling validation and errors on the server

Prerequisites

  • Basic React and Next.js App Router knowledge

What Are Server Actions?

Server Actions are asynchronous functions that run exclusively on the server. They let you handle form submissions and data mutations without building separate API endpoints. You define them with the "use server" directive, and Next.js takes care of generating the network layer, serialization, and hydration behind the scenes.

The big win is simplicity. Instead of creating an API route, writing a fetch call on the client, and wiring up loading and error states manually, you call a function. React and Next.js handle the rest.

"use server";

export async function createPost(formData: FormData) {
  const title = formData.get("title") as string;
  const content = formData.get("content") as string;

  await db.post.create({ data: { title, content } });
  revalidatePath("/posts");
}

That single function replaces an entire API route, a client-side fetch call, and the boilerplate for cache invalidation.

Defining Server Actions

There are two ways to define Server Actions depending on where you need them.

Inline in Server Components

Inside a Server Component, you can define the action directly in the component body:

export default function ContactPage() {
  async function submitContact(formData: FormData) {
    "use server";

    const email = formData.get("email") as string;
    const message = formData.get("message") as string;

    await db.contactSubmission.create({
      data: { email, message, createdAt: new Date() },
    });

    revalidatePath("/contact");
  }

  return (
    <form action={submitContact}>
      <input type="email" name="email" required />
      <textarea name="message" required />
      <button type="submit">Send Message</button>
    </form>
  );
}

Separate Action Files

For reusable actions or when you need to import them in Client Components, put them in a dedicated file with "use server" at the top:

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

import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";

const PostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(10),
  published: z.boolean().default(false),
});

export async function createPost(formData: FormData) {
  const raw = {
    title: formData.get("title"),
    content: formData.get("content"),
    published: formData.get("published") === "on",
  };

  const validated = PostSchema.safeParse(raw);

  if (!validated.success) {
    return { error: validated.error.flatten().fieldErrors };
  }

  const post = await db.post.create({ data: validated.data });
  revalidatePath("/posts");
  redirect(`/posts/${post.id}`);
}

Building Forms with Server Actions

The simplest approach is native HTML form elements with the action prop. These forms work even before JavaScript loads, which is great for accessibility and resilience.

// app/posts/new/page.tsx
import { createPost } from "@/app/actions/posts";

export default function NewPostPage() {
  return (
    <main className="max-w-2xl mx-auto p-6">
      <h1 className="text-2xl font-bold mb-6">Create New Post</h1>
      <form action={createPost} className="space-y-4">
        <div>
          <label htmlFor="title" className="block text-sm font-medium">
            Title
          </label>
          <input
            id="title"
            name="title"
            type="text"
            required
            className="mt-1 block w-full rounded border px-3 py-2"
          />
        </div>
        <div>
          <label htmlFor="content" className="block text-sm font-medium">
            Content
          </label>
          <textarea
            id="content"
            name="content"
            rows={6}
            required
            className="mt-1 block w-full rounded border px-3 py-2"
          />
        </div>
        <div className="flex items-center gap-2">
          <input id="published" name="published" type="checkbox" />
          <label htmlFor="published">Publish immediately</label>
        </div>
        <button
          type="submit"
          className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
        >
          Create Post
        </button>
      </form>
    </main>
  );
}

Handling Pending States with useFormStatus

When a Server Action is processing, you want to show the user that something is happening. The useFormStatus hook gives you that information:

"use client";

import { useFormStatus } from "react-dom";

export function SubmitButton({ label = "Submit" }: { label?: string }) {
  const { pending } = useFormStatus();

  return (
    <button
      type="submit"
      disabled={pending}
      className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50"
    >
      {pending ? "Submitting..." : label}
    </button>
  );
}

Drop this component inside any form that uses a Server Action:

import { createPost } from "@/app/actions/posts";
import { SubmitButton } from "@/components/SubmitButton";

export default function NewPostPage() {
  return (
    <form action={createPost}>
      {/* ...fields... */}
      <SubmitButton label="Create Post" />
    </form>
  );
}

The useFormStatus hook must be used inside a component that is rendered within a <form>. It reads the status of the parent form automatically.

Returning Errors with useActionState

For forms that need to display server-side validation errors, useActionState is the right tool. It wraps your Server Action and gives you back the current state, including any errors:

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

type AuthState = {
  error?: string;
  success?: boolean;
};

export async function login(
  prevState: AuthState,
  formData: FormData
): Promise<AuthState> {
  const email = formData.get("email") as string;
  const password = formData.get("password") as string;

  if (!email || !password) {
    return { error: "Email and password are required" };
  }

  const user = await db.user.findUnique({ where: { email } });

  if (!user || !(await verifyPassword(password, user.passwordHash))) {
    return { error: "Invalid email or password" };
  }

  await createSession(user.id);
  redirect("/dashboard");
}
// app/login/page.tsx
"use client";

import { useActionState } from "react";
import { login } from "@/app/actions/auth";
import { SubmitButton } from "@/components/SubmitButton";

export default function LoginPage() {
  const [state, formAction] = useActionState(login, {});

  return (
    <form action={formAction} className="max-w-sm mx-auto space-y-4 p-6">
      <h1 className="text-2xl font-bold">Log In</h1>

      {state.error && (
        <div className="bg-red-50 text-red-700 p-3 rounded">
          {state.error}
        </div>
      )}

      <input
        name="email"
        type="email"
        placeholder="Email"
        required
        className="block w-full rounded border px-3 py-2"
      />
      <input
        name="password"
        type="password"
        placeholder="Password"
        required
        className="block w-full rounded border px-3 py-2"
      />
      <SubmitButton label="Log In" />
    </form>
  );
}

The key signature change is that your action now takes prevState as its first parameter. React passes the previous return value into each subsequent call, which makes it easy to keep error messages around until the next submission.

Optimistic Updates with useOptimistic

When you want the UI to respond instantly while the server catches up, useOptimistic is the answer. It gives users immediate feedback and rolls back automatically if the action fails:

"use client";

import { useOptimistic } from "react";
import { toggleTodo } from "@/app/actions/todos";

type Todo = {
  id: string;
  text: string;
  completed: boolean;
};

export function TodoList({ todos }: { todos: Todo[] }) {
  const [optimisticTodos, setOptimisticTodos] = useOptimistic(
    todos,
    (current: Todo[], updatedId: string) =>
      current.map((todo) =>
        todo.id === updatedId
          ? { ...todo, completed: !todo.completed }
          : todo
      )
  );

  return (
    <ul className="space-y-2">
      {optimisticTodos.map((todo) => (
        <li key={todo.id} className="flex items-center gap-3">
          <form
            action={async () => {
              setOptimisticTodos(todo.id);
              await toggleTodo(todo.id);
            }}
          >
            <button type="submit" className="flex items-center gap-2">
              <span className={todo.completed ? "line-through opacity-50" : ""}>
                {todo.text}
              </span>
            </button>
          </form>
        </li>
      ))}
    </ul>
  );
}

The optimistic state applies immediately when the user clicks. Once the Server Action resolves and the component re-renders with fresh server data, the optimistic state is replaced with the real data.

Non-Form Mutations

Server Actions are not limited to forms. You can call them from event handlers, effects, or any client-side code:

"use client";

import { deletePost } from "@/app/actions/posts";
import { useTransition } from "react";

export function DeleteButton({ postId }: { postId: string }) {
  const [isPending, startTransition] = useTransition();

  function handleDelete() {
    if (!confirm("Are you sure you want to delete this post?")) return;

    startTransition(async () => {
      await deletePost(postId);
    });
  }

  return (
    <button
      onClick={handleDelete}
      disabled={isPending}
      className="text-red-600 hover:text-red-800 disabled:opacity-50"
    >
      {isPending ? "Deleting..." : "Delete"}
    </button>
  );
}

Wrapping the call in startTransition keeps the UI responsive and gives you a isPending flag for loading states, similar to useFormStatus but usable outside of forms.

Error Handling Patterns

Robust error handling matters. Here is a pattern that separates expected errors from unexpected ones:

"use server";

import { revalidatePath } from "next/cache";

type ActionResult<T = void> =
  | { success: true; data: T }
  | { success: false; error: string };

export async function updateProfile(
  formData: FormData
): Promise<ActionResult> {
  try {
    const name = formData.get("name") as string;
    const bio = formData.get("bio") as string;

    if (!name || name.length < 2) {
      return { success: false, error: "Name must be at least 2 characters" };
    }

    if (bio && bio.length > 500) {
      return { success: false, error: "Bio must be under 500 characters" };
    }

    const session = await getSession();
    if (!session) {
      return { success: false, error: "You must be logged in" };
    }

    await db.user.update({
      where: { id: session.userId },
      data: { name, bio },
    });

    revalidatePath("/profile");
    return { success: true, data: undefined };
  } catch (error) {
    console.error("Failed to update profile:", error);
    return { success: false, error: "Something went wrong. Please try again." };
  }
}

Expected validation errors are returned as part of the result. Unexpected errors are caught, logged, and turned into a user-friendly message. Never expose raw error messages or stack traces to the client.

Revalidation and Redirects

After a mutation, you typically need to refresh cached data or send the user somewhere new. Next.js provides two functions for this:

"use server";

import { revalidatePath } from "next/cache";
import { revalidateTag } from "next/cache";
import { redirect } from "next/navigation";

export async function publishPost(postId: string) {
  await db.post.update({
    where: { id: postId },
    data: { published: true, publishedAt: new Date() },
  });

  // Option 1: Revalidate a specific path
  revalidatePath(`/posts/${postId}`);

  // Option 2: Revalidate all data with a specific cache tag
  revalidateTag("posts");

  // Option 3: Redirect after mutation
  redirect(`/posts/${postId}`);
}

A few things to know:

  • revalidatePath purges the cached data for a specific URL.
  • revalidateTag purges all data fetched with a matching cache tag.
  • redirect must be called outside of a try/catch block because it works by throwing a special error internally.

File Uploads with Server Actions

Handling file uploads is straightforward because FormData natively supports files:

"use server";

import { writeFile } from "fs/promises";
import { join } from "path";
import { revalidatePath } from "next/cache";

export async function uploadAvatar(formData: FormData) {
  const file = formData.get("avatar") as File;

  if (!file || file.size === 0) {
    return { error: "No file provided" };
  }

  if (file.size > 5 * 1024 * 1024) {
    return { error: "File must be under 5MB" };
  }

  if (!file.type.startsWith("image/")) {
    return { error: "File must be an image" };
  }

  const bytes = await file.arrayBuffer();
  const buffer = Buffer.from(bytes);

  const filename = `${Date.now()}-${file.name}`;
  const path = join(process.cwd(), "public", "uploads", filename);
  await writeFile(path, buffer);

  const session = await getSession();
  await db.user.update({
    where: { id: session.userId },
    data: { avatarUrl: `/uploads/${filename}` },
  });

  revalidatePath("/profile");
  return { success: true };
}

For production applications, you would upload to a cloud storage service like S3 or Cloudflare R2 instead of writing to the local filesystem.

Security Considerations

Server Actions create public HTTP endpoints under the hood. Treat them exactly like API routes from a security perspective:

"use server";

import { getSession } from "@/lib/auth";

export async function deleteComment(commentId: string) {
  // Always verify authentication
  const session = await getSession();
  if (!session) {
    throw new Error("Unauthorized");
  }

  // Always verify authorization
  const comment = await db.comment.findUnique({
    where: { id: commentId },
  });

  if (!comment || comment.authorId !== session.userId) {
    throw new Error("Forbidden");
  }

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

Key rules: always authenticate the user, always check authorization, always validate inputs, and never trust data coming from the client.

Wrapping Up

Server Actions simplify the mutation story in Next.js dramatically. You get type-safe server functions, progressive enhancement with native forms, optimistic updates, and built-in cache revalidation without writing a single API endpoint.

Start by replacing your simplest API route with a Server Action. Once you see how much boilerplate disappears, you will want to migrate the rest. The combination of useActionState for error handling, useFormStatus for pending states, and useOptimistic for instant feedback covers nearly every form interaction pattern you will encounter in production applications.