Server Actions
Mutate data from the server using "use server" functions -- no API routes needed.
Search across all documentation pages
Mutate data from the server using "use server" functions -- no API routes needed.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
// 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:
"use server" file<form action> for progressive enhancementuseTransition for pending UIrevalidatePath to refresh cached data after mutationsasync function marked with "use server" (either at the top of a file or inline in a function body within a Server Component).<form action>, React submits the form data to the server and calls the function. This works even without JavaScript enabled (progressive enhancement).revalidatePath() or revalidateTag() to invalidate cached data and trigger a fresh render.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>
);
}// 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";
// ...
}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.
| Alternative | Use When | Don't Use When |
|---|---|---|
| Route Handlers (API routes) | You need a REST endpoint for external clients or webhooks | You only mutate data from your own UI |
SWR mutate + API route | You need instant cache invalidation on the client with SWR | You want progressive enhancement |
| tRPC | You want end-to-end type safety with a dedicated RPC layer | You want to keep things framework-native |
fetch from Client Component | You need full control over request/response handling | Server Actions already cover your use case |
<form action>, Server Actions support progressive enhancementonClickredirect() throws a special Next.js error internally to trigger navigationredirect() outside the try/catch block, after all other logic"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>
);
}Date, Map, Set, typed arrays, and plain objects/arraysFormData is supported for form submissionsuseActionState manages form state and validation, receiving previous state plus FormDatauseTransition only provides an isPending flag and a startTransition wrapperuseActionState when you need to return errors or status from the action// 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 {};
}cookies() and headers() from next/headers inside the action body"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
}<form> with encType="multipart/form-data" or construct FormData manuallyJSON.stringify for file dataformData.get("file") which returns a File objectuseTransition with actionsReviewed by Chris St. John·Last updated Jul 16, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥