Overview
Server Actions are async functions that run on the server, triggered directly from client components. They replace API route handlers for mutations in Next.js applications.
Basic Server Action
// app/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
const CreatePostSchema = z.object({
title: z.string().min(1, "Title is required").max(200),
content: z.string().min(10, "Content must be at least 10 characters"),
tags: z.string().transform(s => s.split(",").map(t => t.trim()).filter(Boolean)),
});
export async function createPost(formData: FormData) {
const parsed = CreatePostSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return { error: parsed.error.flatten() };
}
await db.post.create({ data: parsed.data });
revalidatePath("/posts");
return { success: true };
}
Form with useActionState
// app/posts/new/page.tsx
"use client";
import { useActionState } from "react";
import { createPost } from "../actions";
type State = { error?: { fieldErrors: Record<string, string[]> } } | { success: true } | null;
export default function NewPost() {
const [state, action, isPending] = useActionState<State, FormData>(createPost, null);
return (
<form action={action}>
<div>
<label htmlFor="title">Title</label>
<input id="title" name="title" required />
{state?.error?.fieldErrors.title && (
<p className="error">{state.error.fieldErrors.title[0]}</p>
)}
</div>
<div>
<label htmlFor="content">Content</label>
<textarea id="content" name="content" required />
{state?.error?.fieldErrors.content && (
<p className="error">{state.error.fieldErrors.content[0]}</p>
)}
</div>
<button type="submit" disabled={isPending}>
{isPending ? "Creating..." : "Create Post"}
</button>
</form>
);
}
Optimistic Updates
"use client";
import { useOptimistic } from "react";
import { toggleLike } from "./actions";
function PostCard({ post, userId }: { post: Post; userId: string }) {
const [optimisticLikes, addOptimistic] = useOptimistic(
post.likes,
(state, liked: boolean) =>
liked
? [...state, { userId }]
: state.filter(l => l.userId !== userId)
);
const isLiked = optimisticLikes.some(l => l.userId === userId);
async function handleLike() {
addOptimistic(!isLiked); // Instant UI update
await toggleLike(post.id); // Server action (actual save)
}
return (
<button onClick={handleLike}>
{isLiked ? "Unlike" : "Like"} ({optimisticLikes.length})
</button>
);
}
Authentication Check
"use server";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
export async function deletePost(postId: string) {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
// Check ownership
const post = await db.post.findUnique({ where: { id: postId } });
if (post?.authorId !== session.user.id) {
throw new Error("Unauthorized");
}
await db.post.delete({ where: { id: postId } });
revalidatePath("/posts");
}
Revalidation Strategies
"use server";
import { revalidatePath, revalidateTag } from "next/cache";
// Revalidate a specific route
revalidatePath("/posts");
revalidatePath("/posts/[slug]", "page"); // Dynamic route
// Revalidate all routes with a layout
revalidatePath("/dashboard", "layout");
// Revalidate by cache tag
revalidateTag("posts"); // Any fetch() tagged with "posts"
revalidateTag(`post-${id}`); // Specific post
// Tag fetches for targeted revalidation
const data = await fetch("https://api.example.com/posts", {
next: { tags: ["posts"] }
});
File Upload Action
"use server";
import { put } from "@vercel/blob";
export async function uploadFile(formData: FormData) {
const file = formData.get("file") as File;
if (!file || file.size === 0) {
return { error: "No file provided" };
}
// Validate type and size
if (!file.type.startsWith("image/")) {
return { error: "Only images allowed" };
}
if (file.size > 5 * 1024 * 1024) {
return { error: "Max 5MB" };
}
const blob = await put(file.name, file, { access: "public" });
return { url: blob.url };
}
Common Patterns
// Inline server action (for one-off use)
export default function Page() {
async function handleDelete(formData: FormData) {
"use server";
const id = formData.get("id") as string;
await db.item.delete({ where: { id } });
revalidatePath("/items");
}
return (
<form action={handleDelete}>
<input type="hidden" name="id" value="123" />
<button type="submit">Delete</button>
</form>
);
}
// Programmatic invocation from client
const handleClick = () => startTransition(() => createPost(new FormData()));
Server Actions vs API Routes
| Use Case | Server Action | API Route | |----------|--------------|-----------| | Form submission | Yes — simpler DX | Overkill | | Mutation from client button | Yes | Works but verbose | | External webhook receiver | No | Yes | | Third-party API consumption (no auth) | No | Yes | | File upload (small) | Yes | Works | | Streaming response | No | Yes |