Server Actions in Next.js: Forms, Revalidation and Cache Pitfalls
By Arash Latifi
A practical Next.js App Router guide to Server Actions: calling them from forms, validating input, revalidating paths and tags, and avoiding cache mistakes.
TL;DR: A Server Action is a server-side function a form can call directly; after mutating data you must invalidate with
revalidatePathorrevalidateTag, otherwise the user sees stale data.
If you have been writing a Route Handler for every operation and then fetching it from the client, Server Actions cut that boilerplate significantly. Here is the full path from form to database to cache invalidation.
New to this? Read RSC poisoning and use-client boundaries first, since everything depends on getting the boundary right.
What a Server Action is
Any function marked with use server, or living in an actions.ts file, runs on the server. A form calls it directly and the logic never ships to the browser bundle. Sensitive logic like final pricing or role checks stays private.
// app/actions.ts
"use server";
import { z } from "zod";
import { db } from "@/lib/db";
const productSchema = z.object({
name: z.string().min(2),
price: z.number().positive(),
});
export async function createProduct(prevState: State, formData: FormData) {
const session = await auth();
if (!session?.user) return { error: "unauthorized" };
const parsed = productSchema.safeParse({
name: formData.get("name"),
price: Number(formData.get("price")),
});
if (!parsed.success) return { error: "invalid" };
const product = await db.product.create({ data: parsed.data });
revalidatePath("/admin/products");
return { success: product.slug };
}
Server-side Zod validation is not optional. If you already have a shared schema pattern, the form and Zod guide covers it in more depth.
Wiring it to a form
The form component only needs an action and to read the returned state.
pending is what keeps the user waiting. Without it the button double-submits and you get duplicate rows.
Revalidation, the part everyone misses
In the App Router, fetch results and route handlers are cached. Create a product and skip revalidation, and the list still renders the old copy.
| Tool | Use case | Example |
|---|---|---|
| revalidatePath | Invalidate a specific path | revalidatePath("/admin/products") |
| revalidateTag | Invalidate everything under a data tag | revalidateTag("products") |
| redirect | Navigate after success | redirect("/admin/products") |
Rule of thumb: if a page fetches data, give it a tag and invalidate that same tag in the action. The Next.js 15 cache and PPR guide covers the caching differences in detail.
Common mistakes
Caching user-specific data. If a fetch result depends on the current user, do not cache it. Use cache: "no-store" or the cookie in the key. Wrong caching leaks one user's data to another.
Returning non-serializable values. Return plain data only: strings, numbers, booleans. Classes and Promises cannot cross the action boundary.
No feedback in the form. Without handling pending the user cannot tell whether the request went through. For a full pattern see loading and error states.
Using actions for reads. Actions are for mutations. Fetch data in a Server Component instead.
Review checklist
- [ ] Every action checks access with
auth() - [ ] Server-side
Zodvalidation runs - [ ] Authenticated data is not cached with a tag
- [ ] Cache is invalidated after each mutation
- [ ]
pendingis reflected in the form - [ ] Errors are announced with
role="alert"
FAQ
Q: What is the difference between a Server Action and a Route Handler?
A Route Handler is an endpoint, usually consumed by an external client. A Server Action is for internal mutations from your own components and needs far less code.
Q: Should everything be an action?
No. Reading data in a Server Component is simpler and faster. Keep actions for writes and state changes.
Q: How do errors reach the user?
Return a plain object instead of throwing, and render it with role="alert". Throwing produces a generic error page.