React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

server-actionsmutationsuse-serverformsrevalidation

Server Actions

Mutate data from the server using "use server" functions -- no API routes needed.

Recipe

Quick-reference recipe card -- copy-paste ready.

// app/actions.ts
"use server";
 
import { revalidatePath } from "next/cache";
 
export async function createPost(formData: FormData) {
  const title = formData.get("title") as string;
 
  await db.post.create({ data: { title } });
  revalidatePath("/posts");
}
// app/posts/new/page.tsx (Server Component)
import { createPost } from "@/app/actions";
 
export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" required />
      <button type="submit">Create</button>
    </form>
  );
}

When to reach for this: You need to create, update, or delete data from a form or button click without writing a dedicated API route.

Working Example

// app/actions/todo.ts
"use server";
 
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
 
export async function addTodo(formData: FormData) {
  const text = formData.get("text") as string;
 
  if (!text || text.trim().length === 0) {
    return { error: "Text is required" };
  }
 
  await db.todo.create({ data: { text: text.trim(), done: false } });
  revalidatePath("/todos");
}
 
export async function toggleTodo(id: string) {
  const todo = await db.todo.findUnique({ where: { id } });
  if (!todo) return { error: "Not found" };
 
  await db.todo.update({
    where: { id },
    data: { done: !todo.done },
  });
 
  revalidatePath("/todos");
}
 
export async function deleteTodo(id: string) {
  await db.todo.delete({ where: { id } });
  revalidatePath("/todos");
}
// app/todos/page.tsx (Server Component)
import { db } from "@/lib/db";
import { addTodo } from "@/app/actions/todo";
import { TodoItem } from "./todo-item";
 
export default async function TodosPage() {
  const todos = await db.todo.findMany({ orderBy: { createdAt: "desc" } });
 
  return (
    <main className="max-w-md mx-auto p-6">
      <h1 className="text-2xl font-bold mb-4">Todos</h1>
 
      <form action={addTodo} className="flex gap-2 mb-6">
        <input
          name="text"
          placeholder="What needs doing?"
          className="flex-1 border rounded px-3 py-2"
          required
        />
        <button
          type="submit"
          className="bg-blue-600 text-white px-4 py-2 rounded"
        >
          Add
        </button>
      </form>
 
      <ul className="space-y-2">
        {todos.map((todo) => (
          <TodoItem key={todo.id} todo={todo} />
        ))}
      </ul>
    </main>
  );
}
// app/todos/todo-item.tsx
"use client";
 
import { useTransition } from "react";
import { toggleTodo, deleteTodo } from "@/app/actions/todo";
 
type Todo = { id: string; text: string; done: boolean };
 
export function TodoItem({ todo }: { todo: Todo }) {
  const [isPending, startTransition] = useTransition();
 
  return (
    <li
      className={`flex items-center gap-3 p-2 border rounded ${
        isPending ? "opacity-50" : ""
      }`}
    >
      <input
        type="checkbox"
        checked={todo.done}
        onChange={() => startTransition(() => toggleTodo(todo.id))}
      />
      <span className={todo.done ? "line-through text-gray-400" : "flex-1"}>
        {todo.text}
      </span>
      <button
        onClick={() => startTransition(() => deleteTodo(todo.id))}
        className="text-red-500 text-sm"
      >
        Delete
      </button>
    </li>
  );
}

What this demonstrates:

  • Defining Server Actions in a separate "use server" file
  • Binding a Server Action to a <form action> for progressive enhancement
  • Calling Server Actions from Client Components via useTransition for pending UI
  • Using revalidatePath to refresh cached data after mutations

Deep Dive

How It Works

  • A Server Action is any async function marked with "use server" (either at the top of a file or inline in a function body within a Server Component).
  • When passed to a <form action>, React submits the form data to the server and calls the function. This works even without JavaScript enabled (progressive enhancement).
  • When called from a Client Component event handler, the action is invoked as an RPC -- React serializes the arguments, sends them to the server, and returns the result.
  • Server Actions run in a secure server context. They can access databases, environment variables, and file systems directly.
  • After a mutation, call revalidatePath() or revalidateTag() to invalidate cached data and trigger a fresh render.
  • Server Actions are POST requests under the hood. They are automatically protected against CSRF via same-origin checks.

Variations

Inline Server Action (defined inside a Server Component):

export default function Page() {
  async function handleSubmit(formData: FormData) {
    "use server";
    const email = formData.get("email") as string;
    await subscribeToNewsletter(email);
  }
 
  return (
    <form action={handleSubmit}>
      <input name="email" type="email" required />
      <button type="submit">Subscribe</button>
    </form>
  );
}

Using useActionState for form state and validation:

"use client";
 
import { useActionState } from "react";
import { createUser } from "@/app/actions/user";
 
type State = { error?: string; success?: boolean };
 
export function SignupForm() {
  const [state, formAction, isPending] = useActionState<State, FormData>(
    createUser,
    { error: undefined, success: false }
  );
 
  return (
    <form action={formAction}>
      <input name="email" type="email" required />
      {state.error && <p className="text-red-500">{state.error}</p>}
      <button type="submit" disabled={isPending}>
        {isPending ? "Creating..." : "Sign Up"}
      </button>
    </form>
  );
}

Optimistic updates with useOptimistic:

"use client";
 
import { useOptimistic, useTransition } from "react";
import { addTodo } from "@/app/actions/todo";
 
export function TodoList({ todos }: { todos: Todo[] }) {
  const [optimisticTodos, addOptimistic] = useOptimistic(
    todos,
    (state, newTodo: string) => [
      ...state,
      { id: crypto.randomUUID(), text: newTodo, done: false },
    ]
  );
  const [, startTransition] = useTransition();
 
  return (
    <form
      action={(formData) => {
        const text = formData.get("text") as string;
        startTransition(async () => {
          addOptimistic(text);
          await addTodo(formData);
        });
      }}
    >
      <input name="text" required />
      <button type="submit">Add</button>
      <ul>
        {optimisticTodos.map((t) => (
          <li key={t.id}>{t.text}</li>
        ))}
      </ul>
    </form>
  );
}

TypeScript Notes

// Server Action signature when used with <form action>
export async function myAction(formData: FormData): Promise<void> {
  "use server";
  // ...
}
 
// Server Action with useActionState -- receives previous state + formData
export async function myAction(
  prevState: { error?: string },
  formData: FormData
): Promise<{ error?: string }> {
  "use server";
  // ...
}
 
// Server Action called directly (not from a form)
export async function deleteItem(id: string): Promise<{ success: boolean }> {
  "use server";
  // ...
}

Gotchas

  • Arguments must be serializable -- You cannot pass DOM nodes, class instances, or functions to a Server Action. Fix: Pass only strings, numbers, booleans, FormData, plain objects, arrays, Date, Map, Set, or typed arrays.

  • revalidatePath does not work inside try/catch with redirect -- Calling redirect() throws a special Next.js error. If you wrap it in try/catch, the redirect is swallowed. Fix: Call redirect() outside the try/catch block, after all other logic.

  • Server Actions are POST-only -- They cannot be used for GET requests or idempotent reads. Fix: Use a regular fetch or Server Component for reading data.

  • File uploads need FormData -- You cannot use JSON.stringify for files. Fix: Use a <form> with encType="multipart/form-data" or construct FormData manually in the client.

  • No access to request headers by default -- Server Actions do not automatically receive cookies or headers. Fix: Use cookies() and headers() from next/headers inside the action body.

  • Closure variables are serialized -- If you define an inline "use server" action that closes over a variable, that variable is serialized and sent with the action. Fix: Be aware of sensitive data in closures; validate all inputs on the server.

Alternatives

AlternativeUse WhenDon't Use When
Route Handlers (API routes)You need a REST endpoint for external clients or webhooksYou only mutate data from your own UI
SWR mutate + API routeYou need instant cache invalidation on the client with SWRYou want progressive enhancement
tRPCYou want end-to-end type safety with a dedicated RPC layerYou want to keep things framework-native
fetch from Client ComponentYou need full control over request/response handlingServer Actions already cover your use case

FAQs

What does the "use server" directive actually do?
  • It marks async functions as Server Actions that run exclusively on the server
  • Can be placed at the top of a file (marks all exports) or inline in a function body within a Server Component
  • React serializes the arguments, sends them to the server, and calls the function as an RPC
Do Server Actions work without JavaScript enabled in the browser?
  • Yes, when used with <form action>, Server Actions support progressive enhancement
  • The form submits as a standard POST request when JS is disabled
  • This does not apply when calling actions from event handlers like onClick
Why does calling redirect() inside a try/catch block not work?
  • redirect() throws a special Next.js error internally to trigger navigation
  • Wrapping it in try/catch swallows this error and prevents the redirect
  • Always call redirect() outside the try/catch block, after all other logic
How do you show a pending state when a Server Action is running?
"use client";
import { useTransition } from "react";
import { deleteItem } from "@/app/actions";
 
function DeleteButton({ id }: { id: string }) {
  const [isPending, startTransition] = useTransition();
  return (
    <button
      disabled={isPending}
      onClick={() => startTransition(() => deleteItem(id))}
    >
      {isPending ? "Deleting..." : "Delete"}
    </button>
  );
}
What types of arguments can you pass to a Server Action?
  • Strings, numbers, booleans, Date, Map, Set, typed arrays, and plain objects/arrays
  • FormData is supported for form submissions
  • You cannot pass DOM nodes, class instances, or functions
What is the difference between useActionState and useTransition for Server Actions?
  • useActionState manages form state and validation, receiving previous state plus FormData
  • useTransition only provides an isPending flag and a startTransition wrapper
  • Use useActionState when you need to return errors or status from the action
How do you type a Server Action that is used with useActionState?
// The action receives prevState as its first argument
export async function createUser(
  prevState: { error?: string },
  formData: FormData
): Promise<{ error?: string }> {
  "use server";
  const email = formData.get("email") as string;
  if (!email) return { error: "Email is required" };
  // ...
  return {};
}
How are Server Actions protected against CSRF attacks?
  • Server Actions are POST requests under the hood
  • Next.js automatically applies same-origin checks
  • No additional CSRF token setup is needed
Can you access cookies or headers inside a Server Action?
  • Server Actions do not automatically receive cookies or headers
  • Import and call cookies() and headers() from next/headers inside the action body
  • Both are async in Next.js 15+ and must be awaited
What happens if an inline "use server" action closes over a sensitive variable?
  • The closed-over variable is serialized and sent to the client as part of the action reference
  • This can unintentionally expose sensitive data
  • Always validate all inputs on the server and avoid closing over secrets
How do you implement optimistic updates with Server Actions?
"use client";
import { useOptimistic, useTransition } from "react";
 
function TodoList({ todos }: { todos: Todo[] }) {
  const [optimistic, addOptimistic] = useOptimistic(
    todos,
    (state, newText: string) => [
      ...state,
      { id: crypto.randomUUID(), text: newText, done: false },
    ]
  );
  // use addOptimistic inside startTransition before awaiting the action
}
How do you handle file uploads with Server Actions?
  • Use a <form> with encType="multipart/form-data" or construct FormData manually
  • You cannot use JSON.stringify for file data
  • Access files via formData.get("file") which returns a File object