Introduction š
Server Actions let you write server-side mutation logic as plain async functions and call them directly from your components ā no manually built API route required. They bridge the gap between Client Components and server-side data mutations. This tutorial covers everything from the basics of creating an action to advanced patterns like optimistic updates.
Information
What are Server Actions? š§
A Server Action is an asynchronous function that runs exclusively on the server, marked with the "use server" directive. It can be called from a form's action prop, or invoked directly as a regular function from event handlers.
app/actions.ts
"use server";
export async function createTodo(formData: FormData) {
const title = formData.get("title");
await db.todos.create({ title });
}Why Use Server Actions? š¤
- Eliminates the need to hand-build API routes just for simple mutations.
- Runs on the server, so secrets and database access never reach the client.
- Integrates naturally with <form> for progressive enhancement ā works even before JavaScript loads.
- Pairs with revalidatePath/revalidateTag for automatic cache updates after a mutation.
How Server Actions Work āļø
When a Server Action is called, Next.js sends a special HTTP POST request encoding the arguments, executes the function on the server, and streams the result back ā all without a full page reload.
The "use server" Directive š·ļø
The "use server" directive marks a function ā or an entire file ā as containing Server Actions. It can be placed at the top of a file to apply to every export, or inline at the top of a single function.
app/page.tsx
export default function Page() {
async function like() {
"use server";
await db.likes.increment();
}
return <form action={like}><button type="submit">Like</button></form>;
}Important
Creating Server Actions š ļø
The most common pattern is defining Server Actions in a dedicated file with "use server" at the top, keeping mutation logic organized and importable from anywhere in the app.
app/actions.ts
"use server";
import { db } from "@/lib/db";
export async function deletePost(id: string) {
await db.posts.delete({ where: { id } });
}Invoking Server Actions š
Server Actions can be invoked in two ways: passed directly to a form's action prop, or called imperatively inside an event handler within a Client Component.
components/DeleteButton.tsx
"use client";
import { deletePost } from "@/app/actions";
export default function DeleteButton({ id }: { id: string }) {
return <button onClick={() => deletePost(id)}>Delete</button>;
}Server Actions with Forms š
Passing a Server Action directly to a <form>'s action prop is the most idiomatic pattern ā it works with zero client JavaScript, thanks to progressive enhancement.
components/NewTodo.tsx
import { createTodo } from "@/app/actions";
export default function NewTodo() {
return (
<form action={createTodo}>
<input name="title" type="text" />
<button type="submit">Add Todo</button>
</form>
);
}Form Submission šØ
When a form using a Server Action is submitted, Next.js automatically serializes the form fields into a FormData object and passes it as the first argument to the action.
app/actions.ts
"use server";
export async function submitContact(formData: FormData) {
const name = formData.get("name");
const email = formData.get("email");
await db.contacts.create({ name, email });
}Form Validation ā
Validation typically happens at the start of the action, often using a schema library like Zod to parse and validate the incoming FormData before touching the database.
app/actions.ts
"use server";
import { z } from "zod";
const schema = z.object({
email: z.string().email(),
});
export async function subscribe(formData: FormData) {
const result = schema.safeParse({ email: formData.get("email") });
if (!result.success) {
return { error: "Invalid email address" };
}
await db.subscribers.create({ email: result.data.email });
return { success: true };
}Reading Form Data š
The FormData object passed into a Server Action exposes get(), getAll(), and iteration methods, letting you extract text fields, files, and multi-value fields like checkboxes.
app/actions.ts
"use server";
export async function saveProfile(formData: FormData) {
const name = formData.get("name") as string;
const interests = formData.getAll("interests") as string[];
await db.profiles.update({ name, interests });
}Returning Data š¤
A Server Action can return a plain, serializable value, which becomes available to the calling component ā commonly used to signal success or return newly created data.
app/actions.ts
"use server";
export async function createComment(formData: FormData) {
const comment = await db.comments.create({ text: formData.get("text") });
return { id: comment.id, createdAt: comment.createdAt };
}Returning Errors ā ļø
Rather than throwing for expected validation failures, it's common practice to return a structured error object, which pairs naturally with the useActionState hook on the client.
app/actions.ts
"use server";
export async function login(prevState: any, formData: FormData) {
const password = formData.get("password");
if (!password) {
return { error: "Password is required" };
}
return { success: true };
}Redirecting After Actions āŖļø
Calling redirect() inside a Server Action sends the user to a new route once the mutation completes ā commonly used after creating a resource to navigate to its detail page.
app/actions.ts
"use server";
import { redirect } from "next/navigation";
export async function createPost(formData: FormData) {
const post = await db.posts.create({ title: formData.get("title") });
redirect(`/blog/${post.slug}`);
}Error Handling šØ
Unhandled exceptions thrown inside a Server Action are caught by the nearest error.tsx boundary, while expected, recoverable errors are better returned as structured data for inline display.
app/actions.ts
"use server";
export async function updateAccount(formData: FormData) {
try {
await db.accounts.update(formData.get("id"));
} catch (e) {
return { error: "Failed to update account" };
}
}Optimistic Updates š
Optimistic updates immediately reflect the expected result of a mutation in the UI, before the server confirms it ā making interactions feel instant, with automatic rollback if the action fails.
components/LikeButton.tsx
"use client";
import { useOptimistic } from "react";
import { likePost } from "@/app/actions";
export default function LikeButton({ likes, postId }: { likes: number; postId: string }) {
const [optimisticLikes, addOptimisticLike] = useOptimistic(likes, (state) => state + 1);
async function handleLike() {
addOptimisticLike(1);
await likePost(postId);
}
return <button onClick={handleLike}>ā¤ļø {optimisticLikes}</button>;
}Pending States ā³
The isPending flag returned from useActionState or useFormStatus lets you show a loading indicator or disable a button while a Server Action is in flight.
components/SubmitButton.tsx
"use client";
import { useFormStatus } from "react-dom";
export default function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? "Savingā¦" : "Save"}</button>;
}Loading States š
Combining pending states with disabled inputs and visual feedback ā like a spinner or skeleton ā prevents duplicate submissions and reassures the user their action was received.
components/NewComment.tsx
"use client";
import { useFormStatus } from "react-dom";
import { createComment } from "@/app/actions";
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? "Postingā¦" : "Post"}</button>;
}
export default function NewComment() {
return (
<form action={createComment}>
<textarea name="text" />
<SubmitButton />
</form>
);
}useActionState šļø
useActionState wires a Server Action to component state, tracking the latest result and a pending flag across submissions ā ideal for forms that need to display validation errors.
components/LoginForm.tsx
"use client";
import { useActionState } from "react";
import { login } from "@/app/actions";
export default function LoginForm() {
const [state, formAction, isPending] = useActionState(login, { error: null });
return (
<form action={formAction}>
<input name="password" type="password" />
{state.error && <p>{state.error}</p>}
<button disabled={isPending}>Log in</button>
</form>
);
}useOptimistic š
useOptimistic creates a temporary, client-side version of state that updates immediately on interaction, then reconciles with the real server response once the action resolves.
components/TodoList.tsx
"use client";
import { useOptimistic } from "react";
export default function TodoList({ todos }: { todos: { id: string; text: string }[] }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(todos, (state, newTodo: string) => [
...state,
{ id: "temp", text: newTodo },
]);
return (
<List type="unordered">
{optimisticTodos.map((todo) => <List.Item key={todo.id}>{todo.text}</List.Item>)}
</List>
);
}useFormStatus š¶
useFormStatus reads the status of the parent <form>, and must be called from a component nested inside that form ā not the form component itself.
components/SaveButton.tsx
"use client";
import { useFormStatus } from "react-dom";
export default function SaveButton() {
const { pending, data } = useFormStatus();
return <button disabled={pending}>{pending ? "Savingā¦" : "Save"}</button>;
}Caution
Database Mutations šļø
Server Actions are a natural home for database mutations, since they run exclusively on the server and can call an ORM directly, without exposing any database credentials to the client.
app/actions.ts
"use server";
import { prisma } from "@/lib/prisma";
export async function updateUsername(id: string, formData: FormData) {
await prisma.user.update({
where: { id },
data: { name: formData.get("name") as string },
});
}Authentication š
Server Actions can read session cookies via cookies() to identify the current user before performing any mutation, ensuring every action is tied to an authenticated identity.
app/actions.ts
"use server";
import { cookies } from "next/headers";
export async function updateBio(formData: FormData) {
const session = cookies().get("session")?.value;
if (!session) {
return { error: "Not authenticated" };
}
await db.users.updateBio(session, formData.get("bio"));
}Authorization š”ļø
Beyond checking who a user is, Server Actions should also verify what they're allowed to do ā for example, confirming a user owns a resource before letting them delete it.
app/actions.ts
"use server";
export async function deletePost(id: string) {
const session = await getSession();
const post = await db.posts.findUnique({ where: { id } });
if (post?.authorId !== session.userId) {
throw new Error("Not authorized");
}
await db.posts.delete({ where: { id } });
}Danger
File Uploads š
Server Actions can accept files directly through FormData, using the File API to read and store uploaded content ā such as saving an avatar image to cloud storage.
app/actions.ts
"use server";
export async function uploadAvatar(formData: FormData) {
const file = formData.get("avatar") as File;
const buffer = await file.arrayBuffer();
await storage.upload(file.name, buffer);
}components/AvatarForm.tsx
import { uploadAvatar } from "@/app/actions";
export default function AvatarForm() {
return (
<form action={uploadAvatar}>
<input type="file" name="avatar" accept="image/*" />
<button type="submit">Upload</button>
</form>
);
}Cache Revalidation š
Since Server Actions mutate data, they're the natural place to trigger cache invalidation, ensuring the UI reflects the change immediately after the action completes.
app/actions.ts
"use server";
import { revalidatePath } from "next/cache";
export async function addComment(formData: FormData) {
await db.comments.create({ text: formData.get("text") });
revalidatePath("/blog/post");
}revalidatePath š£ļø
revalidatePath invalidates the cache for a specific route, causing Next.js to regenerate it with fresh data the next time it's visited.
app/actions.ts
"use server";
import { revalidatePath } from "next/cache";
export async function toggleTask(id: string) {
await db.tasks.toggle(id);
revalidatePath("/tasks");
}revalidateTag š·ļø
revalidateTag invalidates every cached fetch() call sharing a given tag, which is useful when the same data appears across multiple, unrelated routes.
app/actions.ts
"use server";
import { revalidateTag } from "next/cache";
export async function updatePrice(id: string, price: number) {
await db.products.updatePrice(id, price);
revalidateTag("products");
}Security Considerations š
- Server Actions are exposed as public HTTP endpoints ā always re-validate input, even if the client already validated it.
- Never assume a client-supplied ID grants access ā always check authorization server-side.
- Rate-limit sensitive actions like password resets or payment mutations.
- Avoid leaking internal error details in returned error messages.
Performance Optimization š
- Use revalidateTag instead of broad revalidatePath calls for more targeted cache invalidation.
- Pair actions with useOptimistic to keep the UI feeling instant, even on slower connections.
- Keep action payloads small ā avoid passing large objects unnecessarily through FormData.
Best Practices ā
- Colocate related Server Actions in a dedicated actions.ts file per feature.
- Always validate and authorize inside the action ā never trust the client.
- Prefer returning structured error objects over throwing for expected validation failures.
- Use useFormStatus and useActionState together for polished, accessible forms.
Common Mistakes ā ļø
- Forgetting "use server" at the top of the actions file or function.
- Calling useFormStatus in the same component that renders the <form>, instead of a child component.
- Skipping server-side authorization checks because the client already hid the button.
- Forgetting to call revalidatePath or revalidateTag, leaving stale cached data visible.