Server Actions: Beyond the Basic Form
Progressive enhancement, optimistic updates, error handling, and integrating server actions with TanStack Query.
Server actions are more than a form submission shortcut. When used correctly they replace entire API route files, enable progressive enhancement, and compose well with client-side state management. Here's what took me a while to figure out.
The Basics: What They Actually Do
A server action is a function that runs on the server but can be called from client components. Mark it with "use server" — either at the top of the file (makes every export a server action) or inside individual async functions.
// app/actions/post.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.insert(posts).values({ title });
revalidatePath("/posts");
}The key thing: revalidatePath and revalidateTag trigger ISR revalidation from inside the action — no separate API route needed.
Progressive Enhancement
Native <form action={serverAction}> works without JavaScript. The form submits, the action runs, the page reloads. Add useTransition and useFormStatus to enhance it progressively.
"use client";
import { useTransition } from "react";
import { createPost } from "@/app/actions/post";
export function PostForm() {
const [isPending, startTransition] = useTransition();
return (
<form action={(formData) => startTransition(() => createPost(formData))}>
<input name="title" required />
<button disabled={isPending}>
{isPending ? "Saving..." : "Create Post"}
</button>
</form>
);
}Without JS: form submits normally. With JS: submit is non-blocking, button shows pending state.
Optimistic Updates with useOptimistic
useOptimistic lets you show a fake update immediately while the server action runs.
"use client";
import { useOptimistic, useTransition } from "react";
import { toggleLike } from "@/app/actions/likes";
export function LikeButton({ post }) {
const [optimisticPost, addOptimistic] = useOptimistic(
post,
(state, liked: boolean) => ({ ...state, liked, likeCount: state.likeCount + (liked ? 1 : -1) })
);
const [, startTransition] = useTransition();
return (
<button
onClick={() =>
startTransition(() => {
addOptimistic(!optimisticPost.liked);
toggleLike(post.id);
})
}
>
{optimisticPost.liked ? "♥" : "♡"} {optimisticPost.likeCount}
</button>
);
}If the server action fails, React rolls back to the real state automatically.
Error Handling
Server actions throw on error. Catch with try/catch and return a typed result — don't throw to the client.
"use server";
type ActionResult = { success: true } | { success: false; error: string };
export async function deletePost(id: string): Promise<ActionResult> {
try {
await db.delete(posts).where(eq(posts.id, id));
revalidatePath("/posts");
return { success: true };
} catch {
return { success: false, error: "Failed to delete post" };
}
}On the client, check the result and render the error:
const result = await deletePost(id);
if (!result.success) toast.error(result.error);With TanStack Query
Server actions and TanStack Query work together. Use the action as the mutation function, then invalidate the relevant queries on success.
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createPost } from "@/app/actions/post";
export function useCreatePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { title: string }) => {
const fd = new FormData();
fd.set("title", data.title);
return createPost(fd);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["posts"] });
},
});
}This pattern gives you the server-side simplicity of actions with the full power of TanStack Query's cache — loading states, background refetches, and devtools all work as expected.
Validation with Zod
Validate inside the action, not just on the client.
"use server";
import { z } from "zod";
const schema = z.object({ title: z.string().min(3).max(120) });
export async function createPost(formData: FormData) {
const parsed = schema.safeParse({ title: formData.get("title") });
if (!parsed.success) {
return { success: false, errors: parsed.error.flatten().fieldErrors };
}
await db.insert(posts).values(parsed.data);
revalidatePath("/posts");
return { success: true };
}Server actions have made most of my API route files unnecessary. The code is closer to the component that uses it, validation lives in one place, and progressive enhancement is free.