Server actions cheatsheet.

Basic action

// app/actions.ts
"use server";

import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";

export async function createPost(formData: FormData) {
  const title = formData.get("title") as string;
  await db.post.create({ data: { title } });
  revalidatePath("/posts");
}

Use in form

// app/posts/new/page.tsx
import { createPost } from "../actions";

export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" required />
      <button>Create</button>
    </form>
  );
}

Works without JS (progressive enhancement). With JS, no full page reload.

Inline server action

export default function Page() {
  async function action(formData: FormData) {
    "use server";
    await db.post.create({ data: { title: formData.get("title") as string } });
    revalidatePath("/posts");
  }
  
  return <form action={action}>...</form>;
}

Defined inside server component.

Calling from client component

"use client";
import { createPost } from "./actions";

export function Button() {
  return <button onClick={() => createPost(new FormData())}>Create</button>;
}

The action is a normal async function in client too.

useActionState

"use client";
import { useActionState } from "react";
import { createPost } from "./actions";

export function Form() {
  const [state, formAction, pending] = useActionState(
    async (prev: string, formData: FormData) => {
      try { await createPost(formData); return "success"; }
      catch (e: any) { return e.message; }
    },
    "",
  );
  
  return (
    <form action={formAction}>
      <input name="title" />
      <button disabled={pending}>Create</button>
      <p>{state}</p>
    </form>
  );
}

State persists across submissions.

useFormStatus

"use client";
import { useFormStatus } from "react-dom";

export function Submit() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? "Saving…" : "Save"}</button>;
}

Reads parent form’s pending state. Place inside <form>.

Validation

"use server";
import { z } from "zod";

const Schema = z.object({
  title: z.string().min(1).max(200),
  body: z.string().min(1),
});

export async function createPost(formData: FormData) {
  const parsed = Schema.safeParse({
    title: formData.get("title"),
    body: formData.get("body"),
  });
  
  if (!parsed.success) {
    return { errors: parsed.error.flatten().fieldErrors };
  }
  
  await db.post.create({ data: parsed.data });
  redirect("/posts");
}

Optimistic updates

"use client";
import { useOptimistic } from "react";

export function Likes({ initial, postId }: { initial: number; postId: number }) {
  const [optimistic, addOptimistic] = useOptimistic(initial, (state, _) => state + 1);
  
  async function like() {
    addOptimistic(1);
    await likeAction(postId);
  }
  
  return <button onClick={like}>{optimistic} likes</button>;
}

Returning data

"use server";

export async function createPost(formData: FormData) {
  const post = await db.post.create({ data: { title: formData.get("title") as string } });
  return { id: post.id };
}

// Client:
const result = await createPost(new FormData());
console.log(result.id);

redirect from action

"use server";
import { redirect } from "next/navigation";

export async function createPost(formData: FormData) {
  const post = await db.post.create({ data: ... });
  redirect(`/posts/${post.id}`);
}

Throws a redirect; don’t try to catch generically.

Setting cookies

"use server";
import { cookies } from "next/headers";

export async function login(formData: FormData) {
  const c = await cookies();
  c.set("session", "...", { httpOnly: true });
  redirect("/dashboard");
}

Bound arguments

export async function deletePost(id: number, formData: FormData) {
  "use server";
  await db.post.delete({ where: { id } });
}

// Usage:
<form action={deletePost.bind(null, post.id)}>
  <button>Delete</button>
</form>

Security

  • Server actions are RPC endpoints under the hood.
  • Always authenticate inside the action:
"use server";

export async function deletePost(id: number) {
  const session = await getSession();
  if (!session) throw new Error("unauthorized");
  await db.post.delete({ where: { id, authorId: session.userId } });
}

Treat them like API routes — server-side trust nothing.

Where to put actions

app/
├── actions/
│   ├── posts.ts        # all post actions
│   └── auth.ts
└── posts/
    └── new/page.tsx    # imports from @/actions/posts

Common mistakes

  • Forgetting "use server" at top of file → not callable from client.
  • Trusting form data without validation.
  • Not revalidating after mutation → stale UI.
  • Returning Response/JSX from server action — must be serializable.
  • Catching redirect() error — never do; let it propagate.

Read this next

If you want my server actions + Zod patterns, they’re 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 .