Server Functions with Zod validation, typed results, granular cache invalidation.
Form submissions are classic in-between state challenges. The user clicks submit, data travels to the server, validation runs, the database updates, and finally the UI reflects the change. Without proper coordination, users see frozen buttons, lose their input on errors, or wonder if anything happened at all.
Server Functions ("use server") handle the entire mutation lifecycle: validation, database writes, cache invalidation, and error recovery. They run on the server but can be called directly from client components—no API routes needed.
A well-structured Server Function handles validation, persistence, cache invalidation, and returns typed results:
'use server';
import { refresh, revalidateTag } from 'next/cache';
const postSchema = z.object({
title: z.string().min(1, 'Title is required'),
content: z.string().min(1, 'Content is required'),
published: z.boolean(),
});
export type ActionResult =
| { success: true }
| { success: false; error: string; formData?: FormValues };
export async function createPost(formData: FormData): Promise<ActionResult> {
const rawData = {
title: formData.get('title') as string,
content: formData.get('content') as string,
published: formData.get('published') === 'on',
};
const result = postSchema.safeParse(rawData);
if (!result.success) {
return { success: false, error: result.error.issues[0].message, formData: rawData };
}
await prisma.post.create({ data: result.data });
revalidateTag('posts', 'max');
refresh();
return { success: true };
}The key insight is returning submitted data when validation fails. This lets forms repopulate—users don't lose their work:
type ActionResult =
| { success: true }
| { success: false; error: string; formData?: FormValues };When used with useActionState, the form automatically repopulates with the returned formData.
After mutations, two things need to happen: background cache invalidation and immediate UI update.
revalidateTag('posts', 'max'); // Mark cache stale, revalidate in background
refresh(); // Force immediate re-render for current userThe 'max' profile tells Next.js to serve stale content while revalidating in the background. Combined with refresh(), the current user sees the update immediately while other users get fresh data on their next request.
For updates and deletes, invalidate both the list and the specific item:
revalidateTag('posts', 'max');
revalidateTag(`post-${slug}`, 'max');
refresh();This ensures the post list updates and any cached detail pages for that specific post are also refreshed.