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

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 .