How Server Actions are compiled, useActionState (the useFormState replacement), useOptimistic for instant UI, the after() API for post-response side effects, and Zod validation patterns.
P-2 — Server Actions: Mutations Done Right
Who this is for: Practitioners who understand data fetching from P-1 and need to handle the other side of the equation — user-initiated mutations. Server Actions are how the App Router handles form submissions, data updates, and any write operation originating from the client. This module covers the full picture: how they actually work, the security implications most tutorials skip, and the patterns that make forms feel instant.
What Server Actions Actually Are
The name "Server Action" sounds like a special framework concept, but the mechanics are simpler than the name implies.
When you mark a function with 'use server', Next.js does three things at build time:
- Moves the function to the server bundle. The code never reaches the browser.
- Generates an endpoint. Next.js creates a unique POST endpoint for this function — the URL is a hash derived from the function's location, not a human-readable path.
- Creates a stub on the client. Instead of the function itself, the browser gets a small proxy that makes a POST request to that endpoint when called.
From the developer's perspective, you call a function. Under the hood, you're making an HTTP POST to an automatically generated endpoint. The function body executes on the server. The result is serialized and sent back to the client.
This is why Server Actions can only receive and return serializable values — they cross the network.
Defining Server Actions
Inline in a Server Component:
tsx// app/posts/new/page.tsx (Server Component) export default function NewPostPage() { async function createPost(formData: FormData) { 'use server'; // ← makes this function a Server Action const title = formData.get('title') as string; const content = formData.get('content') as string; await db.posts.create({ data: { title, content } }); revalidatePath('/blog'); redirect('/blog'); } return ( <form action={createPost}> <input name="title" placeholder="Title" required /> <textarea name="content" placeholder="Content" required /> <button type="submit">Publish</button> </form> ); }
In a dedicated actions file:
ts// app/actions/posts.ts 'use server'; // ← file-level directive — all exports are Server Actions import { revalidatePath, revalidateTag } from 'next/cache'; import { redirect } from 'next/navigation'; import { z } from 'zod'; const CreatePostSchema = z.object({ title: z.string().min(1).max(200), content: z.string().min(1), category: z.string(), }); export async function createPost(formData: FormData) { const raw = { title: formData.get('title'), content: formData.get('content'), category: formData.get('category'), }; const validated = CreatePostSchema.safeParse(raw); if (!validated.success) { return { error: validated.error.flatten().fieldErrors, }; } const post = await db.posts.create({ data: validated.data }); revalidateTag('posts'); redirect(`/blog/${post.slug}`); }
The file-level 'use server' directive is more maintainable for production codebases — all actions in one place, no risk of accidentally marking a utility function as a Server Action.
The Security Implications Nobody Talks About
Server Actions are HTTP endpoints. They're POST endpoints, but they're public — nothing stops someone from calling them directly with curl or Postman. There is no automatic authentication on Server Actions.
This is the most dangerous misconception about Server Actions: because they look like private functions, engineers sometimes assume they're protected by default.
Always authenticate inside Server Actions that touch sensitive data:
ts// app/actions/posts.ts 'use server'; import { auth } from '@/lib/auth'; export async function deletePost(postId: string) { // ❌ Don't assume the caller is authenticated await db.posts.delete({ where: { id: postId } }); } export async function deletePost(postId: string) { // ✅ Always verify inside the action const session = await auth(); if (!session?.user) throw new Error('Unauthorized'); // ✅ Verify the user owns this post const post = await db.posts.findUnique({ where: { id: postId } }); if (post?.authorId !== session.user.id) throw new Error('Forbidden'); await db.posts.delete({ where: { id: postId } }); revalidateTag('posts'); }
Validate all inputs. FormData.get() returns string | File | null. Treat it like untrusted user input from an external API, because that's exactly what it is:
ts// ❌ Trusting the input shape export async function updatePrice(formData: FormData) { const price = formData.get('price') as number; // price is a string, not a number await db.products.update({ where: { id }, data: { price } }); // type error at runtime } // ✅ Validate and parse export async function updatePrice(formData: FormData) { const session = await auth(); if (!session?.user) throw new Error('Unauthorized'); const result = z.object({ productId: z.string().cuid(), price: z.coerce.number().positive().max(99999.99), }).safeParse({ productId: formData.get('productId'), price: formData.get('price'), }); if (!result.success) return { error: result.error.flatten() }; await db.products.update({ where: { id: result.data.productId, ownerId: session.user.id }, data: { price: result.data.price }, }); revalidateTag(`product-${result.data.productId}`); }
z.coerce.number() converts the string "29.99" from FormData into the number 29.99. Always coerce and validate, never cast.
useActionState — Form State Management
useActionState (introduced in React 19, replacing the deprecated useFormState) connects a Server Action to a Client Component's state — giving you access to the action's return value (errors, success messages) and a pending boolean for loading state.
tsx// components/CreatePostForm.tsx 'use client'; import { useActionState } from 'react'; import { createPost } from '@/app/actions/posts'; const initialState = { error: null, success: false }; export default function CreatePostForm() { const [state, formAction, isPending] = useActionState(createPost, initialState); return ( <form action={formAction}> <div> <input name="title" placeholder="Post title" disabled={isPending} className={state.error?.title ? 'border-red-500' : ''} /> {state.error?.title && ( <p className="text-red-400 text-sm mt-1">{state.error.title[0]}</p> )} </div> <div> <textarea name="content" placeholder="Write your post..." disabled={isPending} /> {state.error?.content && ( <p className="text-red-400 text-sm mt-1">{state.error.content[0]}</p> )} </div> <button type="submit" disabled={isPending}> {isPending ? 'Publishing...' : 'Publish Post'} </button> {state.success && ( <p className="text-green-400">Post published successfully!</p> )} </form> ); }
The Server Action must now accept prevState as its first argument:
ts// app/actions/posts.ts 'use server'; interface ActionState { error: Record<string, string[]> | null; success: boolean; } export async function createPost( prevState: ActionState, formData: FormData ): Promise<ActionState> { const session = await auth(); if (!session?.user) return { error: { _form: ['Unauthorized'] }, success: false }; const result = CreatePostSchema.safeParse({ title: formData.get('title'), content: formData.get('content'), }); if (!result.success) { return { error: result.error.flatten().fieldErrors, success: false }; } try { const post = await db.posts.create({ data: { ...result.data, authorId: session.user.id }, }); revalidateTag('posts'); // Note: can't redirect() here when using useActionState — redirect from the Client Component return { error: null, success: true }; } catch (e) { return { error: { _form: ['Failed to create post. Please try again.'] }, success: false }; } }
useActionState is the right pattern for any form that needs to show validation errors, loading states, or success feedback. For simple fire-and-forget actions (a like button, a delete with confirmation dialog), calling the action directly without useActionState is fine.
useOptimistic — Instant UI Feedback
Optimistic UI: update the UI immediately as if the action succeeded, then reconcile with the real server response when it arrives. If the action fails, roll back.
tsx'use client'; import { useOptimistic, useTransition } from 'react'; import { toggleLike } from '@/app/actions/posts'; interface LikeButtonProps { postId: string; initialLiked: boolean; initialCount: number; } export default function LikeButton({ postId, initialLiked, initialCount }: LikeButtonProps) { const [optimisticState, setOptimistic] = useOptimistic( { liked: initialLiked, count: initialCount }, (current, newLiked: boolean) => ({ liked: newLiked, count: newLiked ? current.count + 1 : current.count - 1, }) ); const [isPending, startTransition] = useTransition(); function handleToggle() { startTransition(async () => { const newLiked = !optimisticState.liked; setOptimistic(newLiked); // ← updates UI immediately await toggleLike(postId, newLiked); // ← server action fires in background }); } return ( <button onClick={handleToggle} disabled={isPending} className={optimisticState.liked ? 'text-red-400' : 'text-zinc-400'} > ♥ {optimisticState.count} </button> ); }
The like count updates instantly when clicked — no 200ms wait for the server. If the server action fails, React automatically rolls back optimisticState to the value before the action was called.
useOptimistic is designed specifically for this pattern: immediate UI update, background server sync, automatic rollback on failure.
The after() API — Non-Blocking Side Effects
after() schedules a callback to run after the response has been sent to the client. It's the right place for side effects that shouldn't delay the user experience: analytics events, email notifications, logging, audit trails.
ts// app/actions/posts.ts 'use server'; import { after } from 'next/server'; export async function publishPost(postId: string) { const session = await auth(); if (!session?.user) throw new Error('Unauthorized'); const post = await db.posts.update({ where: { id: postId, authorId: session.user.id }, data: { published: true, publishedAt: new Date() }, }); revalidateTag('posts'); // These run after the response is sent — don't block the user after(async () => { await sendPublishNotificationEmail(session.user.email, post.title); await analytics.track('post_published', { postId, userId: session.user.id }); await slack.notify(`New post published: ${post.title}`); }); return { success: true }; }
Without after(), you'd either block the response waiting for the email to send (bad UX — typically 200-500ms) or fire-and-forget with someEmailFn() without await (works but unhandled promise rejections are hidden and the function might not complete on serverless platforms before the Lambda terminates).
after() explicitly tells the Next.js runtime "keep this serverless function alive until this callback completes, but don't hold the response." On serverless platforms like Vercel, this is guaranteed. On self-hosted Node.js, it runs after the response is flushed.
Calling Server Actions from Client Components
Actions don't have to be tied to <form> — you can call them from any Client Component event handler:
tsx'use client'; import { deletePost } from '@/app/actions/posts'; import { useTransition } from 'react'; import { useRouter } from 'next/navigation'; export default function DeleteButton({ postId }: { postId: string }) { const [isPending, startTransition] = useTransition(); const router = useRouter(); function handleDelete() { if (!confirm('Delete this post?')) return; startTransition(async () => { await deletePost(postId); router.push('/blog'); }); } return ( <button onClick={handleDelete} disabled={isPending}> {isPending ? 'Deleting...' : 'Delete Post'} </button> ); }
useTransition gives you the isPending state without needing useActionState. Use it for button-triggered actions that don't have form validation — it's simpler than useActionState for non-form use cases.
Progressive Enhancement — Forms That Work Without JavaScript
One of the underrated properties of Server Actions with native <form action={serverAction}>: the form submits and the action runs even if JavaScript hasn't loaded yet.
tsx// This form works with or without JavaScript export default function SubscribeForm() { async function subscribe(formData: FormData) { 'use server'; const email = formData.get('email') as string; await addSubscriber(email); redirect('/subscribed'); } return ( <form action={subscribe}> <input name="email" type="email" required /> <button type="submit">Subscribe</button> </form> ); }
Without JavaScript, submitting the form does a traditional HTML form POST, the Server Action runs, and redirect() sends the browser to /subscribed. With JavaScript, the form is progressively enhanced — useActionState and optimistic UI layer on top, but the baseline functionality doesn't depend on them.
This matters more than it sounds. Slow networks, browser extensions that break JS, users who disable scripts — a form that works as a pure HTML form is resilient in a way that onClick handlers are not.
Returning Data from Server Actions
Server Actions can return data that ends up in useActionState's state, or that you await directly in a Client Component:
ts'use server'; export async function getPostPreview(content: string): Promise<{ html: string }> { const html = await renderMarkdown(content); return { html }; }
tsx'use client'; import { getPostPreview } from '@/app/actions/posts'; import { useState } from 'react'; export default function MarkdownEditor() { const [preview, setPreview] = useState(''); async function handlePreview(content: string) { const { html } = await getPostPreview(content); setPreview(html); } return ( <div className="grid grid-cols-2"> <textarea onChange={e => handlePreview(e.target.value)} /> <div dangerouslySetInnerHTML={{ __html: preview }} /> </div> ); }
Server Actions are the right tool here because renderMarkdown might use server-only libraries (syntax highlighting, MDX processing) that can't run in the browser. The action keeps that logic server-side while still being callable from a Client Component.
Error Handling Patterns
Three patterns for handling Server Action errors:
Return errors in the action response (validation failures):
tsreturn { error: { email: ['Invalid email format'] } }; // Handled by useActionState's state
Throw for unexpected failures:
tsthrow new Error('Database unavailable'); // Caught by the nearest error.tsx boundary
Use try/catch with structured returns for recoverable errors:
tstry { await sendEmail(email); return { success: true }; } catch (e) { return { error: { _form: ['Email service unavailable. Try again.'] } }; }
The rule: use return values for expected error states (validation, authorization) that the UI should handle gracefully. Throw for unexpected errors that should render an error boundary.
Where We Go From Here
P-3 covers authentication with Auth.js (NextAuth v5) — a complete auth implementation for the App Router including the new forbidden() and unauthorized() auth interrupt system, RBAC with middleware, and using auth() in Server Components and Server Actions. The auth patterns build directly on Server Actions — you'll use createPost patterns but with real session verification.
P-4 follows with database integration — Prisma, PostgreSQL, and the connection pooling issues that kill serverless applications at scale.