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
useTransitionfor pending UI - Using
revalidatePathto refresh cached data after mutations
Deep Dive
How It Works
- A Server Action is any
asyncfunction 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()orrevalidateTag()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. -
revalidatePathdoes not work inside try/catch with redirect -- Callingredirect()throws a special Next.js error. If you wrap it in try/catch, the redirect is swallowed. Fix: Callredirect()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 useJSON.stringifyfor files. Fix: Use a<form>withencType="multipart/form-data"or constructFormDatamanually in the client. -
No access to request headers by default -- Server Actions do not automatically receive cookies or headers. Fix: Use
cookies()andheaders()fromnext/headersinside 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
| 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 |
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 FormDatais supported for form submissions- You cannot pass DOM nodes, class instances, or functions
What is the difference between useActionState and useTransition for Server Actions?
useActionStatemanages form state and validation, receiving previous state plusFormDatauseTransitiononly provides anisPendingflag and astartTransitionwrapper- Use
useActionStatewhen 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()andheaders()fromnext/headersinside 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>withencType="multipart/form-data"or constructFormDatamanually - You cannot use
JSON.stringifyfor file data - Access files via
formData.get("file")which returns aFileobject
Related
- Fetching -- Reading data in Server Components
- Revalidation -- Refreshing cached data after mutations
- Cookies & Headers -- Accessing request context in actions
- Client Components -- Using
useTransitionwith actions