Server Actions in Next.js 15 represent the most significant shift in how React applications handle data mutations since the introduction of hooks. Instead of wiring up API routes, managing fetch calls, and manually syncing client state, you define an async function on the server and call it directly from your components. The result is less code, better performance, and progressive enhancement out of the box.
This guide covers everything from the fundamentals of the use server directive to advanced patterns like optimistic updates, file uploads, and action composition.
Table of Contents
- What Are Server Actions and How Do They Differ from API Routes?
- The
use serverDirective Under the Hood - Basic Form Mutations
- Progressive Enhancement: Working Without JavaScript
- Revalidation Strategies
- Error Handling and Validation with Zod
- Optimistic Updates with useOptimistic
- Combining Server Actions with React Query or SWR
- Security Considerations
- Server Action Composition and Shared Logic
- File Uploads with Server Actions
- Common Pitfalls and How to Avoid Them
What Are Server Actions and How Do They Differ from API Routes?
An API route is an HTTP endpoint. You define it at a path like /api/create-post, and your client code fetches that path. The server and client are two separate systems that communicate over the network using a protocol you manage.
A Server Action is a function. You write an async function in a server context, and React handles the network boundary for you. From the call site, invoking a Server Action looks identical to calling a regular async function — but under the hood, Next.js serializes the arguments, makes an HTTP POST request to a special internal endpoint, executes the function on the server, and serializes the return value back to the client.
The practical differences matter:
- No manual routing — there is no URL to define, no method to match, no body to parse.
- Type safety by default — the function signature is shared between the call site and the implementation without code generation.
- Co-location — the mutation logic lives next to the component that triggers it.
- Progressive enhancement — HTML forms work without JavaScript because the action can be set directly on the
actionattribute.
API routes are not obsolete. They remain the right choice for webhooks, third-party integrations, streaming responses, and anything that needs a stable, versioned public URL. Server Actions are the right choice for user-initiated data mutations within your application.
The use server Directive Under the Hood
The use server directive is a build-time signal to the Next.js compiler. When it appears at the top of a file, every exported function in that file becomes a Server Action. When it appears at the top of an individual function body, only that function is treated as an action.
// actions/posts.ts — file-level directive
"use server";
export async function createPost(data: FormData) {
// This runs on the server
}
export async function deletePost(id: string) {
// This also runs on the server
}// components/PostForm.tsx — inline directive
export function PostForm() {
async function handleSubmit(data: FormData) {
"use server";
// This single function runs on the server
}
return <form action={handleSubmit}>...</form>;
}At build time, the compiler strips the function body from the client bundle and replaces it with a reference ID — a hash that maps to the server-side implementation. At runtime, calling the function causes React to POST to a Next.js internal route (/api/__actions) with the reference ID and serialized arguments.
Arguments must be serializable. Primitives, plain objects, arrays, FormData, Date, Map, Set, and Uint8Array all work. Class instances with methods, functions, and DOM elements do not.
Return values follow the same rules. If you need to return a component, use redirect or revalidatePath to trigger a re-render instead.
Basic Form Mutations
The simplest Server Action use case is a form submission. Pass the action directly to the action prop of a <form> element:
// app/posts/new/page.tsx
import { createPost } from "@/actions/posts";
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" type="text" required />
<textarea name="body" required />
<button type="submit">Publish</button>
</form>
);
}// actions/posts.ts
"use server";
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
export async function createPost(formData: FormData) {
const title = formData.get("title") as string;
const body = formData.get("body") as string;
const post = await db.post.create({ data: { title, body } });
redirect(`/posts/${post.id}`);
}The redirect call from next/navigation works inside Server Actions and causes the client to navigate after the mutation completes.
For mutations that don't redirect, use the useActionState hook (formerly useFormState) to capture the return value and display feedback:
"use client";
import { useActionState } from "react";
import { createPost } from "@/actions/posts";
export function PostForm() {
const [state, formAction, isPending] = useActionState(createPost, null);
return (
<form action={formAction}>
{state?.error && <p className="error">{state.error}</p>}
{state?.success && <p className="success">Post created!</p>}
<input name="title" type="text" />
<button type="submit" disabled={isPending}>
{isPending ? "Publishing..." : "Publish"}
</button>
</form>
);
}Progressive Enhancement: Working Without JavaScript
When you assign a Server Action to a form's action prop, Next.js renders that as a standard HTML form action pointing to an internal endpoint. If JavaScript fails to load or is disabled, the browser submits the form natively via a full page POST, the Server Action still executes, and the server responds with a redirect or a full HTML page render.
This is not a gimmick. It is a meaningful reliability guarantee for slow networks, accessibility tools, and corporate environments that restrict JavaScript execution. It also means your mutations are functional from the first render before React hydrates.
The key constraint: progressive enhancement only applies when the action is passed directly to a <form> element. If you invoke a Server Action from a button's onClick handler, you lose progressive enhancement. Use form action for the primary submit path and reserve onClick-based invocations for secondary interactions.
Revalidation Strategies
After a mutation, you need to tell Next.js to purge stale cached data. Two functions handle this: revalidatePath and revalidateTag.
revalidatePath
revalidatePath purges the full-route cache for a specific URL path. Use it when the mutation affects data displayed on a known URL:
"use server";
import { revalidatePath } from "next/cache";
export async function updatePost(id: string, formData: FormData) {
await db.post.update({ where: { id }, data: { title: formData.get("title") } });
revalidatePath(`/posts/${id}`);
revalidatePath("/posts"); // also invalidate the listing page
}revalidateTag
revalidateTag purges all cached responses tagged with a specific string. This is more flexible — you can tag fetch calls at data-fetch time and invalidate them by tag at mutation time:
// lib/data.ts
export async function getPosts() {
const posts = await fetch("https://api.example.com/posts", {
next: { tags: ["posts"] },
});
return posts.json();
}// actions/posts.ts
"use server";
import { revalidateTag } from "next/cache";
export async function createPost(formData: FormData) {
await db.post.create({ ... });
revalidateTag("posts"); // invalidates all fetches tagged "posts"
}Use revalidateTag when the same data is rendered at multiple paths, or when the affected paths are not known at mutation time.
Error Handling and Validation with Zod
Never trust FormData values. Validate all input server-side before touching the database. Zod is the standard choice:
"use server";
import { z } from "zod";
import { revalidatePath } from "next/cache";
const PostSchema = z.object({
title: z.string().min(1, "Title is required").max(200, "Title too long"),
body: z.string().min(10, "Body must be at least 10 characters"),
published: z.coerce.boolean().default(false),
});
export async function createPost(prevState: unknown, formData: FormData) {
const parsed = PostSchema.safeParse({
title: formData.get("title"),
body: formData.get("body"),
published: formData.get("published"),
});
if (!parsed.success) {
return {
errors: parsed.error.flatten().fieldErrors,
success: false,
};
}
await db.post.create({ data: parsed.data });
revalidatePath("/posts");
return { errors: null, success: true };
}Return a structured state object rather than throwing errors. Throwing inside a Server Action will cause React to display its error boundary, which is appropriate for unexpected failures but not for validation feedback the user needs to correct their input.
For authentication errors, throw a dedicated error or use redirect("/login"). Wrap database operations in try/catch and return generic messages to the client — never expose raw database error strings.
Optimistic Updates with useOptimistic
useOptimistic lets you immediately update the UI with an assumed outcome while the Server Action runs in the background, then reconcile with the real server state when the action completes:
"use client";
import { useOptimistic, useTransition } from "react";
import { toggleLike } from "@/actions/posts";
export function LikeButton({ postId, initialCount, initialLiked }: Props) {
const [optimisticState, setOptimistic] = useOptimistic(
{ count: initialCount, liked: initialLiked },
(current, action: "like" | "unlike") => ({
count: action === "like" ? current.count + 1 : current.count - 1,
liked: action === "like",
})
);
const [isPending, startTransition] = useTransition();
function handleClick() {
const action = optimisticState.liked ? "unlike" : "like";
startTransition(async () => {
setOptimistic(action);
await toggleLike(postId);
});
}
return (
<button onClick={handleClick} disabled={isPending}>
{optimisticState.liked ? "Unlike" : "Like"} ({optimisticState.count})
</button>
);
}The optimistic update is immediately visible. If the Server Action fails, React rolls back to the previous state. Always wrap setOptimistic and the Server Action call inside startTransition.
Combining Server Actions with React Query or SWR
Server Actions handle mutations. React Query and SWR handle client-side data fetching, caching, and background refetching. They are complementary, not competing.
The integration pattern is straightforward: call the Server Action inside a mutation handler, then invalidate the relevant React Query cache:
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createPost } from "@/actions/posts";
export function PostForm() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: async (formData: FormData) => createPost(formData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["posts"] });
},
});
return (
<form onSubmit={(e) => {
e.preventDefault();
mutation.mutate(new FormData(e.currentTarget));
}}>
...
</form>
);
}Use this pattern when you have complex client-side caching requirements, need fine-grained loading states, or are migrating an existing React Query codebase to App Router incrementally.
Security Considerations
CSRF Protection
Server Actions are protected against CSRF by default. Next.js validates the Origin header on every Server Action request and rejects requests that originate from untrusted origins. You do not need to add CSRF tokens manually.
Input Sanitization
CSRF protection does not sanitize input. A logged-in user can still submit malicious data. Always:
- Validate types and ranges with Zod before any processing.
- Use parameterized queries — your ORM should handle this, but verify it.
- Sanitize HTML if you render user-provided content (
dompurifyon the server). - Check authorization on every action — verify the user owns the resource they are mutating.
Authentication Checks
Do not rely on middleware alone. Verify the session inside every Server Action that touches sensitive data:
"use server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
export async function deletePost(id: string) {
const session = await getServerSession(authOptions);
if (!session?.user) throw new Error("Unauthorized");
const post = await db.post.findUnique({ where: { id } });
if (post?.authorId !== session.user.id) throw new Error("Forbidden");
await db.post.delete({ where: { id } });
}Server Action Composition and Shared Logic
Server Actions are plain async functions. You can call one from another, extract shared validation logic, and build higher-order wrappers:
// lib/action-helpers.ts
import { getServerSession } from "next-auth";
export async function withAuth<T>(
fn: (userId: string) => Promise<T>
): Promise<T> {
const session = await getServerSession(authOptions);
if (!session?.user?.id) throw new Error("Unauthorized");
return fn(session.user.id);
}// actions/posts.ts
"use server";
import { withAuth } from "@/lib/action-helpers";
export async function createPost(formData: FormData) {
return withAuth(async (userId) => {
const validated = PostSchema.parse({ ... });
return db.post.create({ data: { ...validated, authorId: userId } });
});
}This pattern keeps authorization logic centralized while individual actions remain focused on their domain concerns.
File Uploads with Server Actions
File uploads work through FormData. The File object is available natively in Node.js 18+ and is fully supported in Server Actions:
"use server";
import { put } from "@vercel/blob";
export async function uploadAvatar(formData: FormData) {
const file = formData.get("avatar") as File;
if (!file || file.size === 0) {
return { error: "No file provided" };
}
if (file.size > 5 * 1024 * 1024) {
return { error: "File too large (max 5MB)" };
}
const allowedTypes = ["image/jpeg", "image/png", "image/webp"];
if (!allowedTypes.includes(file.type)) {
return { error: "Invalid file type" };
}
const blob = await put(`avatars/${crypto.randomUUID()}`, file, {
access: "public",
});
await db.user.update({
where: { id: getCurrentUserId() },
data: { avatarUrl: blob.url },
});
return { url: blob.url };
}For large files, prefer getting a pre-signed upload URL from the Server Action and uploading directly to the storage provider from the client. This avoids routing large payloads through your Next.js server.
Common Pitfalls and How to Avoid Them
Forgetting "use client" on components that use action state hooks. useActionState, useOptimistic, and useTransition are client hooks. The component file must have "use client" at the top.
Placing "use server" in a client component file. You cannot mix "use client" and "use server" in the same file. Define actions in a separate file and import them.
Not wrapping Server Action calls in transitions. Calling a Server Action outside of startTransition means React cannot defer the update, and the UI may appear frozen during the action. Always use useTransition when invoking actions imperatively.
Returning non-serializable values. Returning a class instance, a function, or a circular object reference will throw a runtime error. Stick to plain JSON-serializable structures.
Skipping server-side authorization. Server Actions are callable by anyone who can reverse-engineer the action ID from your client bundle. Always enforce authorization inside the action, not just in the UI.
Revalidating too broadly. Calling revalidatePath("/") invalidates all pages and destroys your caching strategy. Be specific about what you invalidate.
Ignoring the isPending state. Disabling the submit button during isPending prevents double-submissions, which can cause duplicate database records. Always disable interactive elements while an action is in flight.
Server Actions are a mature, production-ready primitive in Next.js 15. Used correctly, they reduce boilerplate, improve type safety, and enable progressive enhancement with minimal ceremony. The patterns above cover the full spectrum from simple forms to complex, secure, optimistic UIs.