Server Actions
Server Actions and mutations for handling form submissions, data mutations, and revalidation in Next.js
You are an expert in Next.js Server Actions for building secure, progressive-enhancement-friendly data mutations.
## Key Points
- Always validate inputs on the server with a schema library like Zod. Never trust client data.
- Return structured state objects from actions for `useActionState` so the UI can display field-level errors.
- Use `revalidatePath` or `revalidateTag` after mutations to keep the UI in sync with the database.
- Keep actions in dedicated files (`app/actions/*.ts`) for organization and reuse.
- Use `.bind()` for passing IDs rather than hidden form fields — it is type-safe and avoids exposing values in the DOM.
- Wrap non-form calls in `startTransition` to keep the UI responsive during mutations.
- **Missing `"use server"` directive**: Forgetting the directive at the top of the file or inside the function body causes the action to run on the client, leading to errors or data leaks.
- **Returning non-serializable data**: Server Actions communicate via a serialization boundary. Returning class instances, functions, or Dates (use ISO strings) will fail.
- **Not handling errors**: An unhandled throw inside a Server Action surfaces as an opaque error in production. Always use try/catch and return user-friendly error state.
- **Calling `redirect()` inside try/catch**: `redirect()` throws a special error internally. Calling it inside a try block catches that throw. Call `redirect()` outside the try/catch.
- **Over-revalidating**: Using `revalidatePath("/", "layout")` clears the entire cache. Be targeted with revalidation to preserve performance.skilldb get nextjs-skills/Server ActionsFull skill: 281 linesServer Actions — Next.js
You are an expert in Next.js Server Actions for building secure, progressive-enhancement-friendly data mutations.
Overview
Server Actions are asynchronous functions that run on the server. They can be called from both Server and Client Components to handle form submissions, data mutations, and side effects. They use the "use server" directive and integrate natively with the Next.js caching and revalidation system.
Core Philosophy
Server Actions represent a return to the simplicity of traditional form handling, enhanced with modern capabilities. Instead of wiring up API routes, writing fetch calls, managing loading states, and synchronizing client and server data manually, you write an async function with "use server" and bind it to a form's action attribute. The framework handles the RPC plumbing, CSRF protection, serialization, and progressive enhancement automatically.
The design prioritizes progressive enhancement as a default. A form that uses a Server Action works without JavaScript — the browser submits a standard POST request, the server processes it, and returns the updated page. When JavaScript is available, React intercepts the submission, keeps the page interactive during the mutation, and streams back the updated UI. This means your mutations are resilient by default, not just when you remember to add a fallback.
Server Actions are not a general-purpose RPC mechanism — they are mutation primitives. They are designed for writes (create, update, delete) and side effects (sending emails, revalidating caches), not for reading data. For data fetching, use Server Components. This separation keeps the mental model clean: Server Components read, Server Actions write, and the revalidation system keeps them in sync.
Core Concepts
Defining Server Actions
Inline in a Server Component:
// app/posts/page.tsx (Server Component)
export default function Posts() {
async function createPost(formData: FormData) {
"use server";
const title = formData.get("title") as string;
await db.post.create({ data: { title } });
revalidatePath("/posts");
}
return (
<form action={createPost}>
<input name="title" required />
<button type="submit">Create</button>
</form>
);
}
In a separate file (recommended for reuse):
// app/actions/posts.ts
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
export async function createPost(formData: FormData) {
const title = formData.get("title") as string;
await db.post.create({ data: { title } });
revalidatePath("/posts");
redirect("/posts");
}
export async function deletePost(id: string) {
await db.post.delete({ where: { id } });
revalidatePath("/posts");
}
Using in Client Components
"use client";
import { createPost } from "@/app/actions/posts";
import { useActionState } from "react";
export function CreatePostForm() {
const [state, formAction, isPending] = useActionState(createPost, null);
return (
<form action={formAction}>
<input name="title" required />
<button type="submit" disabled={isPending}>
{isPending ? "Creating..." : "Create Post"}
</button>
{state?.error && <p className="text-red-600">{state.error}</p>}
</form>
);
}
Binding Additional Arguments
Use .bind() to pass extra arguments beyond FormData:
"use client";
import { updatePost } from "@/app/actions/posts";
export function EditButton({ postId }: { postId: string }) {
const updateWithId = updatePost.bind(null, postId);
return (
<form action={updateWithId}>
<input name="title" />
<button type="submit">Update</button>
</form>
);
}
The action receives the bound argument first:
// app/actions/posts.ts
"use server";
export async function updatePost(id: string, formData: FormData) {
const title = formData.get("title") as string;
await db.post.update({ where: { id }, data: { title } });
revalidatePath("/posts");
}
Implementation Patterns
Validated Actions with Zod
"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),
content: z.string().min(10, "Content must be at least 10 characters"),
});
type State = {
errors?: Record<string, string[]>;
message?: string;
} | null;
export async function createPost(
prevState: State,
formData: FormData
): Promise<State> {
const parsed = PostSchema.safeParse({
title: formData.get("title"),
content: formData.get("content"),
});
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors };
}
try {
await db.post.create({ data: parsed.data });
revalidatePath("/posts");
return { message: "Post created successfully" };
} catch {
return { message: "Database error: failed to create post" };
}
}
Optimistic Updates
"use client";
import { useOptimistic } from "react";
import { toggleLike } from "@/app/actions/likes";
export function LikeButton({ liked, count }: { liked: boolean; count: number }) {
const [optimistic, setOptimistic] = useOptimistic(
{ liked, count },
(current, newLiked: boolean) => ({
liked: newLiked,
count: current.count + (newLiked ? 1 : -1),
})
);
return (
<form
action={async () => {
setOptimistic(!optimistic.liked);
await toggleLike();
}}
>
<button type="submit">
{optimistic.liked ? "Unlike" : "Like"} ({optimistic.count})
</button>
</form>
);
}
Non-Form Invocations
Server Actions can be called outside forms using startTransition:
"use client";
import { useTransition } from "react";
import { deletePost } from "@/app/actions/posts";
export function DeleteButton({ id }: { id: string }) {
const [isPending, startTransition] = useTransition();
return (
<button
disabled={isPending}
onClick={() =>
startTransition(async () => {
await deletePost(id);
})
}
>
{isPending ? "Deleting..." : "Delete"}
</button>
);
}
Revalidation Strategies
"use server";
import { revalidatePath, revalidateTag } from "next/cache";
export async function updateProduct(id: string, formData: FormData) {
await db.product.update({ where: { id }, data: { /* ... */ } });
// Revalidate a specific path
revalidatePath("/products");
// Revalidate a specific page only (not nested layouts)
revalidatePath("/products", "page");
// Revalidate by cache tag
revalidateTag("products");
// Revalidate everything (rarely needed)
revalidatePath("/", "layout");
}
Best Practices
- Always validate inputs on the server with a schema library like Zod. Never trust client data.
- Return structured state objects from actions for
useActionStateso the UI can display field-level errors. - Use
revalidatePathorrevalidateTagafter mutations to keep the UI in sync with the database. - Keep actions in dedicated files (
app/actions/*.ts) for organization and reuse. - Use
.bind()for passing IDs rather than hidden form fields — it is type-safe and avoids exposing values in the DOM. - Wrap non-form calls in
startTransitionto keep the UI responsive during mutations.
Common Pitfalls
- Missing
"use server"directive: Forgetting the directive at the top of the file or inside the function body causes the action to run on the client, leading to errors or data leaks. - Returning non-serializable data: Server Actions communicate via a serialization boundary. Returning class instances, functions, or Dates (use ISO strings) will fail.
- Not handling errors: An unhandled throw inside a Server Action surfaces as an opaque error in production. Always use try/catch and return user-friendly error state.
- Calling
redirect()inside try/catch:redirect()throws a special error internally. Calling it inside a try block catches that throw. Callredirect()outside the try/catch. - Over-revalidating: Using
revalidatePath("/", "layout")clears the entire cache. Be targeted with revalidation to preserve performance.
Anti-Patterns
-
Using Server Actions for data fetching. Server Actions are mutation primitives, not read operations. Calling a Server Action to load a list of products adds an unnecessary POST request and bypasses the caching, streaming, and memoization that Server Components provide for free. Fetch data in Server Components; mutate data with Server Actions.
-
Calling
redirect()inside a try/catch block.redirect()works by throwing a special error internally. If you call it inside atryblock, the catch clause intercepts the throw and the redirect silently fails. Always callredirect()after the try/catch, or in a finally-like position outside the error handling scope. -
Skipping input validation because the form has client-side checks. Client-side validation is a UX convenience, not a security measure. Server Actions are publicly callable POST endpoints. An attacker can submit arbitrary data directly. Always validate with Zod or a similar library on the server, treating every input as untrusted.
-
Returning non-serializable values from Server Actions. Server Actions communicate across a serialization boundary. Returning
Dateobjects, class instances,Map,Set, or functions will fail silently or throw. Return plain objects with primitive values, and convert dates to ISO strings. -
Defining dozens of inline Server Actions in a single page component. Inline
"use server"functions are convenient for quick prototyping, but a page with ten inline actions becomes unreadable and untestable. Extract actions into dedicatedapp/actions/*.tsfiles for reuse, testing, and clear separation of concerns.
Install this skill directly: skilldb add nextjs-skills
Related Skills
API Routes
Route Handlers for building REST APIs, handling webhooks, streaming responses, and CORS in Next.js App Router
App Router
App Router fundamentals including file-based routing, layouts, loading states, and parallel routes in Next.js
Authentication
Authentication patterns using NextAuth.js (Auth.js) and Clerk for protecting routes and managing sessions in Next.js
Data Fetching
Data fetching and caching strategies including Server Components, fetch options, ISR, and the Next.js cache layers
Deployment
Deployment strategies for Next.js including Vercel, self-hosting with Node.js, Docker containers, and static export
Image Optimization
Image optimization with next/image, asset handling, fonts, and performance tuning for Next.js applications