Server actions in Next.js 15 are the cleanest mutation pattern in TypeScript. Type-safe, no API layer, native to the framework. This post is the cookbook.
A typed mutation
// app/users/actions.ts
"use server";
import { z } from "zod";
import { revalidatePath } from "next/cache";
const Schema = z.object({
email: z.string().email(),
fullName: z.string().min(1).max(120),
});
export async function createUser(prev: unknown, formData: FormData) {
const parsed = Schema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors };
}
const user = await db.insert(users).values(parsed.data).returning();
revalidatePath("/users");
return { success: true, userId: user[0].id };
}
Pure server function. Type-safe input. Validation. Cache invalidation.
Wired to a form
"use client";
import { useActionState } from "react";
import { createUser } from "./actions";
export function CreateUserForm() {
const [state, formAction] = useActionState(createUser, null);
return (
<form action={formAction}>
<input name="email" />
{state?.errors?.email && <p>{state.errors.email.join(", ")}</p>}
<input name="fullName" />
{state?.errors?.fullName && <p>{state.errors.fullName.join(", ")}</p>}
<button>Create</button>
{state?.success && <p>Created!</p>}
</form>
);
}
The action returns either errors or success. The component reflects that state. No useState/useEffect wiring.
For React 19 server actions deeper coverage.
Optimistic UI
"use client";
import { useOptimistic } from "react";
export function CommentList({ initial, postId }: Props) {
const [optimistic, addOptimistic] = useOptimistic(
initial,
(state, newC: string) => [...state, { id: -1, text: newC, pending: true }]
);
async function action(formData: FormData) {
const text = formData.get("text") as string;
addOptimistic(text);
await postComment(postId, text);
}
return (
<>
<ul>{optimistic.map(c => <li key={c.id}>{c.text}{c.pending && " ..."}</li>)}</ul>
<form action={action}>
<input name="text" />
<button>Send</button>
</form>
</>
);
}
UI updates instantly; server confirmation reconciles the optimistic state.
Redirects after mutation
"use server";
import { redirect } from "next/navigation";
export async function deleteUser(id: number) {
await db.delete(users).where(eq(users.id, id));
redirect("/users"); // throws redirect; doesn't return
}
redirect from next/navigation short-circuits. After deletion, the user goes to the list page automatically.
Error handling
For server-side errors:
"use server";
export async function action(prev: unknown, fd: FormData) {
try {
await doWork(fd);
return { success: true };
} catch (e) {
if (e instanceof ConflictError) {
return { error: "conflict", message: e.message };
}
console.error(e);
return { error: "internal" };
}
}
Error is part of the return shape. Client renders it.
CSRF
Next.js automatically protects server actions with origin checks. You don’t need to add CSRF tokens manually.
Authorization
"use server";
export async function deletePost(id: number) {
const session = await auth();
if (!session) throw new Error("not authenticated");
const post = await db.query.posts.findFirst({ where: eq(posts.id, id) });
if (!post) throw new Error("not found");
if (post.authorId !== session.user.id) throw new Error("forbidden");
await db.delete(posts).where(eq(posts.id, id));
revalidatePath(`/users/${session.user.id}`);
}
Always check on the server. Never trust client-supplied “I’m allowed.”
For Authentication in 2026 .
Cache invalidation
After every mutation, invalidate what changed:
revalidatePath("/users"); // page
revalidatePath("/users", "layout"); // layout cascade
revalidateTag("user-42"); // tag
Tags work with fetch calls that include next: { tags: ["user-42"] }. Selective invalidation; faster than path-based.
Multiple actions in one form
<form>
<input name="title" />
<button formAction={save}>Save</button>
<button formAction={publish}>Publish</button>
</form>
Two actions; the button picks. Useful for save-vs-publish, draft-vs-submit shapes.
Common mistakes
1. Not invalidating
User submits; UI shows old data. Always revalidatePath / revalidateTag.
2. Trusting client input
Always validate server-side. Client validation is UX; server validation is security.
3. Heavy work in the action
Long-running mutation blocks the UI. Push to a background job; respond fast. See Background Jobs — same shape in any language.
4. No optimistic UI for fast actions
User clicks; UI freezes 500ms; feels broken. useOptimistic for snappy.
5. Passing actions across client/server boundaries clumsily
Server actions are functions. Pass them as action prop or import directly. Don’t try to serialize them.
Read this next
- React 19 Server Actions and
use - Next.js 15 Server Components in Production
- Drizzle ORM Deep Dive
- Authentication in 2026
If you want a Next 15 + Drizzle + auth + server actions starter, it’s at rajpoot.dev .
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .