13.1 Server Actions
What Are Server Actions?
Server Actions is a feature provided in Next.js 15 that allows you to call server-side functions directly from the client. You can perform server operations such as form submission and data mutation without creating separate API endpoints.
In the traditional approach, mutating data required writing an API route and calling it from the client with fetch. Server Actions makes this process much simpler.
Key Concepts
| Concept | Description |
|---|---|
"use server" | Directive that declares a function as a Server Action |
| Form handling | Connect a form with <form action={serverAction}> |
| Optimistic updates | Update the UI first and reflect server response later |
revalidatePath | Invalidate the cache for a specific path to show fresh data |
revalidateTag | Invalidate cache based on tags |
The "use server" Directive
File-level Declaration
Adding "use server" at the top of a file makes all exported functions in that file Server Actions.
// app/actions/todo.ts
"use server";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
export async function createTodo(formData: FormData) {
const title = formData.get("title") as string;
if (!title || title.trim() === "") {
throw new Error("Todo title is required.");
}
await db.todo.create({
data: { title: title.trim(), completed: false },
});
revalidatePath("/todos");
}
export async function deleteTodo(id: number) {
await db.todo.delete({ where: { id } });
revalidatePath("/todos");
}
export async function toggleTodo(id: number, completed: boolean) {
await db.todo.update({
where: { id },
data: { completed: !completed },
});
revalidatePath("/todos");
}
Function-level Declaration
You can also declare inline Server Actions inside a Server Component.
// app/todos/page.tsx
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
export default async function TodosPage() {
const todos = await db.todo.findMany({ orderBy: { createdAt: "desc" } });
async function addTodo(formData: FormData) {
"use server";
const title = formData.get("title") as string;
await db.todo.create({ data: { title, completed: false } });
revalidatePath("/todos");
}
return (
<div>
<h1>Todo List</h1>
<form action={addTodo}>
<input name="title" placeholder="Enter new todo" required />
<button type="submit">Add</button>
</form>
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
</div>
);
}
Form Handling
Basic Form Handling
The most powerful use case for Server Actions is HTML form handling. Passing a server action function to the action attribute of a <form> makes the form work even without JavaScript (Progressive Enhancement).
// app/contact/page.tsx
import { redirect } from "next/navigation";
async function submitContact(formData: FormData) {
"use server";
const name = formData.get("name") as string;
const email = formData.get("email") as string;
const message = formData.get("message") as string;
// Validation
if (!name || !email || !message) {
throw new Error("All fields are required.");
}
// Email format check
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
throw new Error("Invalid email format.");
}
// Save to DB or send email
await saveContactToDb({ name, email, message });
// Redirect on success
redirect("/contact/success");
}
export default function ContactPage() {
return (
<form action={submitContact} className="space-y-4">
<div>
<label htmlFor="name">Name</label>
<input id="name" name="name" type="text" required />
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
</div>
<div>
<label htmlFor="message">Message</label>
<textarea id="message" name="message" required />
</div>
<button type="submit">Send</button>
</form>
);
}
Managing Form State with useActionState
useActionState (Next.js 15, React 19) makes it easy to handle the result of a Server Action on the client.
// app/actions/auth.ts
"use server";
export type ActionState = {
success: boolean;
message: string;
errors?: Record<string, string>;
};
export async function loginAction(
prevState: ActionState,
formData: FormData
): Promise<ActionState> {
const email = formData.get("email") as string;
const password = formData.get("password") as string;
const errors: Record<string, string> = {};
if (!email) errors.email = "Please enter your email.";
if (!password) errors.password = "Please enter your password.";
if (password && password.length < 8)
errors.password = "Password must be at least 8 characters.";
if (Object.keys(errors).length > 0) {
return { success: false, message: "There are input errors.", errors };
}
try {
// Actual authentication logic
const user = await authenticateUser(email, password);
if (!user) {
return { success: false, message: "Incorrect email or password." };
}
return { success: true, message: "Login successful!" };
} catch (error) {
return { success: false, message: "A server error occurred." };
}
}
// app/login/page.tsx
"use client";
import { useActionState } from "react";
import { loginAction, ActionState } from "@/app/actions/auth";
const initialState: ActionState = { success: false, message: "" };
export default function LoginPage() {
const [state, formAction, isPending] = useActionState(
loginAction,
initialState
);
return (
<form action={formAction} className="max-w-md mx-auto p-6">
<h1 className="text-2xl font-bold mb-4">Login</h1>
{state.message && (
<div
className={`p-3 rounded mb-4 ${
state.success
? "bg-green-100 text-green-800"
: "bg-red-100 text-red-800"
}`}
>
{state.message}
</div>
)}
<div className="mb-4">
<label htmlFor="email" className="block mb-1">
Email
</label>
<input
id="email"
name="email"
type="email"
className="w-full border rounded px-3 py-2"
/>
{state.errors?.email && (
<p className="text-red-500 text-sm mt-1">{state.errors.email}</p>
)}
</div>
<div className="mb-6">
<label htmlFor="password" className="block mb-1">
Password
</label>
<input
id="password"
name="password"
type="password"
className="w-full border rounded px-3 py-2"
/>
{state.errors?.password && (
<p className="text-red-500 text-sm mt-1">{state.errors.password}</p>
)}
</div>
<button
type="submit"
disabled={isPending}
className="w-full bg-blue-600 text-white py-2 rounded disabled:opacity-50"
>
{isPending ? "Logging in..." : "Login"}
</button>
</form>
);
}
Optimistic Updates
Optimistic updates is a technique where you update the UI first — assuming the operation will succeed — without waiting for the server response. This greatly improves the user experience.
It uses the useOptimistic hook from React 19.
// app/todos/TodoList.tsx
"use client";
import { useOptimistic, useTransition } from "react";
import { toggleTodo, deleteTodo } from "@/app/actions/todo";
type Todo = {
id: number;
title: string;
completed: boolean;
};
type OptimisticAction =
| { type: "toggle"; id: number }
| { type: "delete"; id: number };
export default function TodoList({ todos }: { todos: Todo[] }) {
const [isPending, startTransition] = useTransition();
const [optimisticTodos, updateOptimistic] = useOptimistic(
todos,
(state: Todo[], action: OptimisticAction) => {
if (action.type === "toggle") {
return state.map((todo) =>
todo.id === action.id
? { ...todo, completed: !todo.completed }
: todo
);
}
if (action.type === "delete") {
return state.filter((todo) => todo.id !== action.id);
}
return state;
}
);
const handleToggle = (todo: Todo) => {
startTransition(async () => {
updateOptimistic({ type: "toggle", id: todo.id });
await toggleTodo(todo.id, todo.completed);
});
};
const handleDelete = (id: number) => {
startTransition(async () => {
updateOptimistic({ type: "delete", id });
await deleteTodo(id);
});
};
return (
<ul className="space-y-2">
{optimisticTodos.map((todo) => (
<li
key={todo.id}
className="flex items-center gap-3 p-3 border rounded"
>
<input
type="checkbox"
checked={todo.completed}
onChange={() => handleToggle(todo)}
className="w-4 h-4"
/>
<span
className={`flex-1 ${todo.completed ? "line-through text-gray-400" : ""}`}
>
{todo.title}
</span>
<button
onClick={() => handleDelete(todo.id)}
className="text-red-500 hover:text-red-700 text-sm"
>
Delete
</button>
</li>
))}
{isPending && (
<li className="text-gray-400 text-sm text-center py-2">
Updating...
</li>
)}
</ul>
);
}
Revalidation (revalidatePath, revalidateTag)
Next.js caches data. When you mutate data with a Server Action, stale data will be displayed if you don't invalidate the cache. Use revalidatePath and revalidateTag to refresh the cache.
revalidatePath — Path-based Revalidation
// app/actions/post.ts
"use server";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
export async function createPost(formData: FormData) {
const title = formData.get("title") as string;
const content = formData.get("content") as string;
const slug = title.toLowerCase().replace(/\s+/g, "-");
const post = await db.post.create({
data: { title, content, slug, publishedAt: new Date() },
});
// Revalidate the list page and the new post page
revalidatePath("/posts");
revalidatePath(`/posts/${post.slug}`);
return post;
}
export async function updatePost(
id: number,
formData: FormData
) {
const title = formData.get("title") as string;
const content = formData.get("content") as string;
const post = await db.post.update({
where: { id },
data: { title, content, updatedAt: new Date() },
});
// Use "layout" type to revalidate all sub-paths under the given path
revalidatePath("/posts", "layout");
}
export async function deletePost(id: number, slug: string) {
await db.post.delete({ where: { id } });
revalidatePath("/posts");
revalidatePath(`/posts/${slug}`);
}
revalidateTag — Tag-based Revalidation
Attach tags when fetching data, then invalidate the cache by tag when related data changes.
// app/lib/data.ts
import { unstable_cache } from "next/cache";
// Cache with tags
export const getPosts = unstable_cache(
async () => {
const response = await fetch("https://api.example.com/posts");
return response.json();
},
["posts-list"],
{
tags: ["posts"],
revalidate: 3600, // auto-revalidate every hour
}
);
export const getPostBySlug = unstable_cache(
async (slug: string) => {
const response = await fetch(`https://api.example.com/posts/${slug}`);
return response.json();
},
["post-detail"],
{
tags: ["posts", `post-${slug}`],
}
);
// app/actions/post.ts
"use server";
import { revalidateTag } from "next/cache";
export async function publishPost(id: number) {
await db.post.update({ where: { id }, data: { published: true } });
// Invalidate all caches with the "posts" tag
revalidateTag("posts");
}
Real-world Example — Comment System
Implementing a complete CRUD comment system with Server Actions.
// app/actions/comment.ts
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
export type CommentActionState = {
success: boolean;
message: string;
commentId?: number;
};
// Create comment
export async function createComment(
postSlug: string,
prevState: CommentActionState,
formData: FormData
): Promise<CommentActionState> {
const session = await auth();
if (!session?.user) {
return { success: false, message: "Login is required." };
}
const content = formData.get("content") as string;
if (!content || content.trim().length < 2) {
return { success: false, message: "Comment must be at least 2 characters." };
}
if (content.length > 500) {
return { success: false, message: "Comment must be 500 characters or less." };
}
const post = await db.post.findUnique({ where: { slug: postSlug } });
if (!post) {
return { success: false, message: "Post not found." };
}
const comment = await db.comment.create({
data: {
content: content.trim(),
postId: post.id,
userId: session.user.id,
},
});
revalidatePath(`/posts/${postSlug}`);
return { success: true, message: "Comment posted.", commentId: comment.id };
}
// Delete comment
export async function deleteComment(
commentId: number,
postSlug: string
): Promise<CommentActionState> {
const session = await auth();
if (!session?.user) {
return { success: false, message: "Login is required." };
}
const comment = await db.comment.findUnique({ where: { id: commentId } });
if (!comment) {
return { success: false, message: "Comment not found." };
}
// Only the author or an admin can delete
if (comment.userId !== session.user.id && session.user.role !== "admin") {
return { success: false, message: "You do not have permission to delete this." };
}
await db.comment.delete({ where: { id: commentId } });
revalidatePath(`/posts/${postSlug}`);
return { success: true, message: "Comment deleted." };
}
// app/posts/[slug]/CommentSection.tsx
"use client";
import { useActionState, useOptimistic, useTransition } from "react";
import {
createComment,
deleteComment,
CommentActionState,
} from "@/app/actions/comment";
import { useSession } from "next-auth/react";
type Comment = {
id: number;
content: string;
createdAt: Date;
user: { id: string; name: string; image: string };
};
const initialState: CommentActionState = { success: false, message: "" };
export default function CommentSection({
postSlug,
initialComments,
}: {
postSlug: string;
initialComments: Comment[];
}) {
const { data: session } = useSession();
const [isPending, startTransition] = useTransition();
const [optimisticComments, removeOptimistic] = useOptimistic(
initialComments,
(state: Comment[], deletedId: number) =>
state.filter((c) => c.id !== deletedId)
);
const createCommentWithSlug = createComment.bind(null, postSlug);
const [state, formAction, isSubmitting] = useActionState(
createCommentWithSlug,
initialState
);
const handleDelete = (commentId: number) => {
startTransition(async () => {
removeOptimistic(commentId);
await deleteComment(commentId, postSlug);
});
};
return (
<section className="mt-8">
<h2 className="text-xl font-bold mb-4">
{optimisticComments.length} Comments
</h2>
{/* Comment list */}
<div className="space-y-4 mb-6">
{optimisticComments.map((comment) => (
<div key={comment.id} className="p-4 border rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<img
src={comment.user.image}
alt={comment.user.name}
className="w-8 h-8 rounded-full"
/>
<span className="font-medium">{comment.user.name}</span>
<span className="text-sm text-gray-500">
{new Date(comment.createdAt).toLocaleDateString("en-US")}
</span>
</div>
{session?.user?.id === comment.user.id && (
<button
onClick={() => handleDelete(comment.id)}
disabled={isPending}
className="text-red-500 text-sm hover:underline disabled:opacity-50"
>
Delete
</button>
)}
</div>
<p className="text-gray-700">{comment.content}</p>
</div>
))}
</div>
{/* Comment form */}
{session ? (
<form action={formAction} className="space-y-3">
{state.message && (
<p
className={`text-sm ${state.success ? "text-green-600" : "text-red-600"}`}
>
{state.message}
</p>
)}
<textarea
name="content"
placeholder="Write a comment... (max 500 characters)"
maxLength={500}
required
className="w-full border rounded-lg p-3 resize-none h-24"
/>
<button
type="submit"
disabled={isSubmitting}
className="bg-blue-600 text-white px-4 py-2 rounded-lg disabled:opacity-50"
>
{isSubmitting ? "Posting..." : "Post Comment"}
</button>
</form>
) : (
<p className="text-gray-500">
You must{" "}
<a href="/login" className="text-blue-600 underline">
log in
</a>{" "}
to post a comment.
</p>
)}
</section>
);
}
Expert Tips
1. Accessing Cookies and Headers in Server Actions
"use server";
import { cookies, headers } from "next/headers";
export async function getTheme() {
const cookieStore = await cookies();
return cookieStore.get("theme")?.value ?? "light";
}
export async function setTheme(theme: "light" | "dark") {
const cookieStore = await cookies();
cookieStore.set("theme", theme, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24 * 365, // 1 year
});
}
export async function getRequestInfo() {
const headerStore = await headers();
const userAgent = headerStore.get("user-agent");
const ip = headerStore.get("x-forwarded-for");
return { userAgent, ip };
}
2. Handling Multiple Server Actions in One Form
// app/profile/page.tsx
"use client";
import { updateProfile, deleteAccount } from "@/app/actions/profile";
export default function ProfilePage() {
return (
<div>
{/* Profile update form */}
<form action={updateProfile}>
<input name="name" placeholder="Name" />
<button type="submit">Update Profile</button>
</form>
{/* Dynamically select action with formAction */}
<form>
<button formAction={updateProfile}>Save</button>
<button formAction={deleteAccount} type="submit">
Delete Account
</button>
</form>
</div>
);
}
3. Using Server Action Results with redirect
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
export async function createAndRedirect(formData: FormData) {
const item = await db.item.create({
data: { name: formData.get("name") as string },
});
revalidatePath("/items");
// redirect works by throwing, so call it outside try/catch
redirect(`/items/${item.id}`);
}
4. Error Boundary Handling
// app/todos/error.tsx
"use client";
export default function ErrorBoundary({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div className="p-6 bg-red-50 rounded-lg">
<h2 className="text-xl font-bold text-red-700 mb-2">An Error Occurred</h2>
<p className="text-red-600 mb-4">{error.message}</p>
<button
onClick={reset}
className="bg-red-600 text-white px-4 py-2 rounded"
>
Try Again
</button>
</div>
);
}
5. Stronger Type Safety in Server Actions with zod
"use server";
import { z } from "zod";
import { revalidatePath } from "next/cache";
const TodoSchema = z.object({
title: z.string().min(1, "Title is required.").max(100, "Title must be 100 characters or less."),
priority: z.enum(["low", "medium", "high"]).default("medium"),
dueDate: z.string().optional(),
});
export async function createTodoValidated(
prevState: { success: boolean; message: string; errors?: Record<string, string[]> },
formData: FormData
) {
const raw = {
title: formData.get("title"),
priority: formData.get("priority"),
dueDate: formData.get("dueDate"),
};
const result = TodoSchema.safeParse(raw);
if (!result.success) {
return {
success: false,
message: "Please check your input.",
errors: result.error.flatten().fieldErrors,
};
}
await db.todo.create({ data: result.data });
revalidatePath("/todos");
return { success: true, message: "Todo added successfully." };
}
6. Server Action Middleware Pattern — Auth Wrapper
Abstract repetitive authentication checks into a wrapper function.
// lib/action-guard.ts
import { auth } from "@/lib/auth";
type ServerAction<T extends unknown[], R> = (...args: T) => Promise<R>;
export function withAuth<T extends unknown[], R>(
action: (userId: string, ...args: T) => Promise<R>
): ServerAction<T, R> {
return async (...args: T): Promise<R> => {
const session = await auth();
if (!session?.user?.id) {
throw new Error("Authentication required.");
}
return action(session.user.id, ...args);
};
}
// Usage example
// app/actions/secure.ts
"use server";
import { withAuth } from "@/lib/action-guard";
import { revalidatePath } from "next/cache";
export const createSecurePost = withAuth(async (userId, formData: FormData) => {
const title = formData.get("title") as string;
const post = await db.post.create({ data: { title, authorId: userId } });
revalidatePath("/posts");
return post;
});