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 .