Skip to main content

Server Actions

Server Actions let you run server-side code directly from your components. They replace the need for API routes in many cases, especially for form submissions and data mutations.

Defining a Server Action

A Server Action is an async function marked with "use server". You can define them inline in Server Components or in a separate file.

In a Separate File

Create app/actions.ts:

"use server";

import { revalidatePath } from "next/cache";

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

  // Save to database (example)
  await db.post.create({
    data: { title, content },
  });

  revalidatePath("/posts");
}

Using in a Form

Pass the action directly to a <form>:

import { createPost } from "@/app/actions";

export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" placeholder="Post title" required />
      <textarea name="content" placeholder="Write your post..." required />
      <button type="submit">Create Post</button>
    </form>
  );
}

No onSubmit, no fetch, no API route. The form submits directly to the server function.

Form Handling with useActionState

For showing loading states, errors, and success messages, use the useActionState hook. This requires a Client Component:

"use client";

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

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

export function PostForm() {
  const [state, formAction, isPending] = useActionState(
    async (prevState: State, formData: FormData) => {
      try {
        await createPost(formData);
        return { success: true };
      } catch {
        return { error: "Failed to create post" };
      }
    },
    {} as State
  );

  return (
    <form action={formAction}>
      <input name="title" placeholder="Post title" required />
      <textarea name="content" placeholder="Write your post..." required />

      {state.error && <p className="text-red-500">{state.error}</p>}
      {state.success && <p className="text-green-500">Post created!</p>}

      <button type="submit" disabled={isPending}>
        {isPending ? "Creating..." : "Create Post"}
      </button>
    </form>
  );
}

Mutations with Arguments

Server Actions can accept arguments beyond FormData using .bind():

"use server";

import { revalidatePath } from "next/cache";

export async function deletePost(postId: string) {
  await db.post.delete({ where: { id: postId } });
  revalidatePath("/posts");
}

export async function toggleLike(postId: string, formData: FormData) {
  const userId = await getCurrentUserId();
  await db.like.upsert({
    where: { postId_userId: { postId, userId } },
    create: { postId, userId },
    update: {},
  });
  revalidatePath(`/posts/${postId}`);
}
import { deletePost, toggleLike } from "@/app/actions";

export function PostActions({ postId }: { postId: string }) {
  const deleteWithId = deletePost.bind(null, postId);
  const likeWithId = toggleLike.bind(null, postId);

  return (
    <div>
      <form action={likeWithId}>
        <button type="submit">Like</button>
      </form>
      <form action={deleteWithId}>
        <button type="submit">Delete</button>
      </form>
    </div>
  );
}

Validation

Always validate input on the server. Never trust client-side validation alone:

"use server";

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

const PostSchema = z.object({
  title: z.string().min(1, "Title is required").max(200),
  content: z.string().min(10, "Content must be at least 10 characters"),
});

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

  const result = PostSchema.safeParse(raw);
  if (!result.success) {
    return { error: result.error.flatten().fieldErrors };
  }

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

Revalidation Strategies

After a mutation, you need to update the cache so users see fresh data:

"use server";

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

export async function updatePost(postId: string, formData: FormData) {
  await db.post.update({
    where: { id: postId },
    data: {
      title: formData.get("title") as string,
      content: formData.get("content") as string,
    },
  });

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

  // Option 2: Revalidate all paths matching a layout
  revalidatePath("/posts", "layout");

  // Option 3: Revalidate by cache tag
  revalidateTag("posts");
}

When fetching data, you can tag your requests so revalidateTag knows what to invalidate:

const posts = await fetch("https://api.example.com/posts", {
  next: { tags: ["posts"] },
});

Redirecting After an Action

Use redirect() to send the user to a new page after a mutation:

"use server";

import { redirect } from "next/navigation";

export async function createPost(formData: FormData) {
  const post = await db.post.create({
    data: {
      title: formData.get("title") as string,
      content: formData.get("content") as string,
    },
  });

  redirect(`/posts/${post.id}`);
}

Note: redirect() throws internally, so do not call it inside a try/catch block.

Error Handling

For graceful error handling, return error state instead of throwing:

"use server";

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

export async function createPost(formData: FormData): Promise<ActionResult> {
  try {
    const title = formData.get("title") as string;
    if (!title) return { success: false, error: "Title is required" };

    await db.post.create({ data: { title } });
    return { success: true };
  } catch {
    return { success: false, error: "Something went wrong. Please try again." };
  }
}

Summary

  • Server Actions are async functions marked with "use server".
  • Pass them directly to form action props for zero-boilerplate mutations.
  • Use useActionState for loading states and error feedback in Client Components.
  • Always validate input on the server with a library like Zod.
  • Use revalidatePath or revalidateTag to refresh cached data after mutations.
  • Use redirect() to navigate after successful actions (outside try/catch).