Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
// app/actions.ts
"use server";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
export async function createPost(formData: FormData) {
const title = formData.get("title") as string;
const body = formData.get("body") as string;
await db.insert("posts", { title, body, createdAt: new Date() });
revalidatePath("/posts");
}// app/posts/NewPostForm.tsx
"use client";
import { createPost } from "../actions";
export default function NewPostForm() {
return (
<form action={createPost}>
<input name="title" placeholder="Title" required />
<textarea name="body" placeholder="Write your post..." required />
<button type="submit">Publish</button>
</form>
);
}When to reach for this: Use Server Actions for any data mutation (create, update, delete) that needs to run on the server -- database writes, file uploads, sending emails, calling third-party APIs with secrets.
// A complete todo app with server actions, validation, and error handling
// app/actions.ts
"use server";
import { z } from "zod";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
const TodoSchema = z.object({
text: z.string().min(1, "Todo text is required").max(200, "Too long"),
});
export type ActionResult = {
success: boolean;
error?: string;
};
export async function addTodo(_prev: ActionResult, formData: FormData): Promise<ActionResult> {
const parsed = TodoSchema.safeParse({ text: formData.get("text") });
if (!parsed.success) {
return { success: false, error: parsed.error.errors[0].message };
}
try {
await db.insert("todos", {
text: parsed.data.text,
completed: false,
createdAt: new Date(),
});
revalidatePath("/todos");
return { success: true };
} catch {
return { success: false, error: "Failed to save todo" };
}
}
export async function toggleTodo(id: string) {
const todo = await db.findById("todos", id);
if (!todo) throw new Error("Not found");
await db.update("todos", id, { completed: !todo.completed });
revalidatePath("/todos");
}
export async function deleteTodo(id: string) {
await db.delete("todos", id);
revalidatePath("/todos");
}// app/todos/TodoApp.tsx
"use client";
import { useActionState, useOptimistic, useTransition } from "react";
import { addTodo, toggleTodo, deleteTodo, type ActionResult } from "../actions";
type Todo = { id: string; text: string; completed: boolean };
export default function TodoApp({ todos }: { todos: Todo[] }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(state, newText: string) => [
...state,
{ id: "optimistic", text: newText, completed: false },
]
);
const [state, formAction, isPending] = useActionState(
async (prev: ActionResult, formData: FormData) => {
const text = formData.get("text") as string;
addOptimisticTodo(text);
return addTodo(prev, formData);
},
{ success: true }
);
return (
<div>
<h1>Todos</h1>
<form action={formAction}>
<input name="text" placeholder="What needs doing?" disabled={isPending} />
<button type="submit" disabled={isPending}>
{isPending ? "Adding..." : "Add"}
</button>
{state.error && <p className="text-red-500">{state.error}</p>}
</form>
<ul>
{optimisticTodos.map((todo) => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
</div>
);
}
function TodoItem({ todo }: { todo: Todo }) {
const [isPending, startTransition] = useTransition();
return (
<li className={isPending ? "opacity-50" : ""}>
<label>
<input
type="checkbox"
checked={todo.completed}
onChange={() => startTransition(() => toggleTodo(todo.id))}
/>
<span className={todo.completed ? "line-through" : ""}>{todo.text}</span>
</label>
<button onClick={() => startTransition(() => deleteTodo(todo.id))}>
Delete
</button>
</li>
);
}What this demonstrates:
"use server" file exporting multiple server actionsuseActionState for form state managementstartTransition"use server" tells the bundler to create a network endpoint for that function. The client receives a reference ID, not the function body.<form action={}> where React automatically collects FormData, or (2) called directly like await deleteItem(id) inside a startTransition.revalidatePath or revalidateTag triggers re-rendering of affected Server Components after the action completes.Inline server actions in Server Components:
// The action is defined inline and closes over server-side data
export default async function LikePage() {
let likes = await db.getLikes();
async function addLike() {
"use server";
await db.incrementLikes();
}
return (
<div>
<p>Likes: {likes}</p>
<form action={addLike}>
<button type="submit">Like</button>
</form>
</div>
);
}Calling server actions outside forms:
"use client";
import { useTransition } from "react";
import { deleteItem } from "./actions";
function DeleteButton({ id }: { id: string }) {
const [isPending, startTransition] = useTransition();
return (
<button
disabled={isPending}
onClick={() => startTransition(async () => {
await deleteItem(id);
})}
>
{isPending ? "Deleting..." : "Delete"}
</button>
);
}Binding extra arguments with .bind:
// Partially apply the id so the form only sends FormData
import { updateItem } from "./actions";
function EditForm({ id }: { id: string }) {
const updateWithId = updateItem.bind(null, id);
return (
<form action={updateWithId}>
<input name="name" />
<button type="submit">Save</button>
</form>
);
}useActionState follow the signature (prevState: T, formData: FormData) => Promise<T>.(id: string, formData: FormData) => Promise<void>.ActionResult pattern (returning { success, error? }) for type-safe error handling instead of throwing."use server" directive requires the function to be async. Fix: Always declare server actions with async function.useOptimistic to update the UI immediately while actions process in order.cookies() or headers() from next/headers inside the action body.
| Approach | When to choose |
|---|---|
| Server Actions | Standard data mutations in React 19 with framework support |
| API Routes | When you need REST endpoints consumed by non-React clients |
| tRPC | End-to-end type safety without framework-specific conventions |
| GraphQL mutations | Complex data graphs with multiple clients |
| Client-side fetch + API | When you need full control over HTTP method, headers, caching |
"use server" marks Server Functions (Server Actions), not Server Components."use client" to opt out of Server Components."use server" is exclusively for functions that run on the server but are called from client code (forms, event handlers)."use server" at the top of a component file will turn every export into a Server Action, not a Server Component - and will error because components aren't valid actions.Yes. Server actions can be called directly from event handlers wrapped in startTransition:
const [isPending, startTransition] = useTransition();
<button onClick={() => startTransition(async () => {
await deleteItem(id);
})}>
Delete
</button>"use server";
import { z } from "zod";
const Schema = z.object({
text: z.string().min(1).max(200),
});
export async function addItem(_prev: Result, formData: FormData) {
const parsed = Schema.safeParse({ text: formData.get("text") });
if (!parsed.success) {
return { success: false, error: parsed.error.errors[0].message };
}
// proceed with valid data
}Use .bind() to partially apply arguments so the form only sends FormData:
const updateWithId = updateItem.bind(null, id);
<form action={updateWithId}>
<input name="name" />
<button type="submit">Save</button>
</form>revalidatePath("/path") or revalidateTag("tag") inside the server actionuseOptimistic to update the UI immediately while actions process in order"use server" are serializable across the server-client boundary"use server" inside the function body; they close over server-side variables at render time"use server" at the top of the file and export multiple actionstype ActionResult = { success: boolean; error?: string };
// Signature: (prevState: T, formData: FormData) => Promise<T>
export async function myAction(
_prev: ActionResult,
formData: FormData
): Promise<ActionResult> {
return { success: true };
}(id: string, formData: FormData) => Promise<void>.bind(null, id), the resulting function signature is (formData: FormData) => Promise<void>cookies() or headers() from next/headers inside the action body<form> elementsReviewed by Chris St. John·Last updated Jul 19, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥