Server Actions - Call server-side functions directly from client components
Recipe
// 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.
Working Example
// 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:
- A
"use server"file exporting multiple server actions - Zod validation inside a server action
- Returning structured results (success/error) from actions
- Binding a server action to
useActionStatefor form state management - Calling server actions from event handlers via
startTransition - Optimistic updates layered on top of server actions
Deep Dive
How It Works
- A function (or file) marked with
"use server"tells the bundler to create a network endpoint for that function. The client receives a reference ID, not the function body. - When a client calls a server action, React serializes the arguments, sends an HTTP POST to the server, executes the function, and returns the serialized result.
- Server actions can be used in two ways: (1) passed to
<form action={}>where React automatically collectsFormData, or (2) called directly likeawait deleteItem(id)inside astartTransition. - The arguments and return values must be serializable -- the same rules as Server Component props apply.
- Server actions run sequentially by default per request. Multiple form submissions are queued, not run in parallel.
- In frameworks like Next.js,
revalidatePathorrevalidateTagtriggers re-rendering of affected Server Components after the action completes.
Variations
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>
);
}TypeScript Notes
- Server actions used with
useActionStatefollow the signature(prevState: T, formData: FormData) => Promise<T>. - When binding extra arguments, the bound arguments come first:
(id: string, formData: FormData) => Promise<void>. - Use the
ActionResultpattern (returning{ success, error? }) for type-safe error handling instead of throwing.
Gotchas
- Server actions must be async -- The
"use server"directive requires the function to beasync. Fix: Always declare server actions withasync function. - Arguments must be serializable -- You cannot pass DOM nodes, class instances, or non-serializable objects. Fix: Extract the serializable data you need (strings, numbers, IDs) before calling the action.
- Throwing errors in actions -- If a server action throws, the error crosses the network boundary and can crash the client. Fix: Catch errors inside the action and return a result object. Use error boundaries as a safety net.
- Actions queue sequentially -- Rapid form submissions are queued, not parallelized. The UI may feel sluggish. Fix: Use
useOptimisticto update the UI immediately while actions process in order. - Closure capture in inline actions -- Inline server actions close over server-side variables at render time. Those values are serialized and sent to the client as encrypted hidden arguments. Fix: Be aware that closures increase payload size. Prefer action files for complex logic.
- No access to request headers by default -- Server actions do not automatically receive cookies or headers as arguments. Fix: Use framework-provided helpers like
cookies()orheaders()fromnext/headersinside the action body.
Alternatives
| 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 |
FAQs
Does "use server" mark a file as a Server Component?
- No — this is a common misconception.
"use server"marks Server Functions (Server Actions), not Server Components. - Server Components are the default in React 19 / Next.js App Router — no directive is needed.
- You only add
"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).- Putting
"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.
What does the "use server" directive do?
- It tells the bundler to create a network endpoint (RPC) for the function
- The client receives a reference ID, not the function body
- When called from the client, React serializes arguments, sends an HTTP POST, executes on the server, and returns the serialized result
Can server actions be used outside of forms?
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>How do you validate input inside a server action?
"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
}How do you bind extra arguments to a server action?
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>How do you trigger revalidation after a server action completes?
- In Next.js, use
revalidatePath("/path")orrevalidateTag("tag")inside the server action - This triggers re-rendering of affected Server Components with fresh data
- The revalidation runs after the action function returns
Are multiple form submissions processed in parallel?
- No. Server actions run sequentially by default per request
- Rapid form submissions are queued, not parallelized
- Use
useOptimisticto update the UI immediately while actions process in order
Gotcha: What happens if a server action throws an error?
- The error crosses the network boundary and can crash the client
- Always catch errors inside the action and return a result object instead of throwing
- Use error boundaries as a safety net for unexpected failures
Gotcha: Can you pass a regular function (not a server action) from a Server Component to a Client Component?
- No. Only functions marked with
"use server"are serializable across the server-client boundary - Regular closures, class instances, and DOM nodes cannot be passed as props to Client Components
- Extract the serializable data you need (strings, numbers, IDs) before passing
What is the difference between inline server actions and action files?
- Inline actions are defined inside a Server Component with
"use server"inside the function body; they close over server-side variables at render time - Action files have
"use server"at the top of the file and export multiple actions - Inline closures increase payload size because captured values are serialized; prefer action files for complex logic
How do you type a server action used with useActionState in TypeScript?
type 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 };
}How do you type a server action with bound arguments in TypeScript?
- Bound arguments come first in the function signature:
(id: string, formData: FormData) => Promise<void> - After
.bind(null, id), the resulting function signature is(formData: FormData) => Promise<void>
How do you access cookies or headers inside a server action?
- Server actions do not automatically receive cookies or headers as arguments
- Use framework-provided helpers like
cookies()orheaders()fromnext/headersinside the action body
Related
- Server Components -- The rendering model that server actions complement
- Form Actions -- Wiring server actions to
<form>elements - useOptimistic -- Showing instant feedback while actions execute
- use() Hook -- Consuming data returned by server actions