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.
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
In a dedicated actions file:
ts
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
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
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
The Server Action must now accept prevState as its first argument:
ts
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
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
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
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
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
tsx
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):
ts
Throw for unexpected failures:
ts
Use try/catch with structured returns for recoverable errors:
ts
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, up next, covers optimistic UI — useOptimistic and useActionState for making these Server Actions feel instant instead of leaving the user staring at a spinner during the round-trip.
P-4 covers authentication with Auth.js (NextAuth v5) — a complete auth implementation for the App Router including the 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-5 follows with database integration — Prisma, PostgreSQL, and the connection pooling issues that kill serverless applications at scale.
Knowledge Check
What happens under the hood when a function is marked with the 'use server' directive?
Why must you manually authenticate and authorize requests inside Server Actions?
What is the primary purpose of the after() API in a Server Action?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
// app/posts/new/page.tsx (Server Component)exportdefaultfunctionNewPostPage(){asyncfunctioncreatePost(formData:FormData){'use server';// ← makes this function a Server Actionconst title = formData.get('title')asstring;const content = formData.get('content')asstring;await db.posts.create({ data:{ title, content }});revalidatePath('/blog');redirect('/blog');}return(<formaction={createPost}><inputname="title"placeholder="Title"required/><textareaname="content"placeholder="Content"required/><buttontype="submit">Publish</button></form>);}
// app/actions/posts.ts'use server';// ← file-level directive — all exports are Server Actionsimport{ 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(),});exportasyncfunctioncreatePost(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}`);}
// app/actions/posts.ts'use server';import{ auth }from'@/lib/auth';// ❌ Don't assume the caller is authenticatedexportasyncfunctiondeletePostUnsafe(postId:string){await db.posts.delete({ where:{ id: postId }});}// ✅ Always verify inside the actionexportasyncfunctiondeletePost(postId:string){const session =awaitauth();if(!session?.user)thrownewError('Unauthorized');// ✅ Verify the user owns this postconst post =await db.posts.findUnique({ where:{ id: postId }});if(post?.authorId !== session.user.id)thrownewError('Forbidden');await db.posts.delete({ where:{ id: postId }});revalidateTag('posts');}
// ❌ Trusting the input shapeexportasyncfunctionupdatePrice(formData: FormData){const price = formData.get('price')asnumber;// price is a string, not a numberawait db.products.update({ where:{ id }, data:{ price }});// type error at runtime}// ✅ Validate and parseexportasyncfunctionupdatePrice(formData: FormData){const session =awaitauth();if(!session?.user)thrownewError('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}`);}
// app/actions/posts.ts'use server';interfaceActionState{ error: Record<string,string[]>|null; success:boolean;}exportasyncfunctioncreatePost( prevState: ActionState, formData: FormData
):Promise<ActionState>{const session =awaitauth();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: redirect() itself works fine with useActionState — the reason it's// not called here is this try/catch. redirect() works by throwing a special// control-flow signal that Next.js intercepts; a generic catch block here// would swallow that throw and treat it as a real failure. Returning a// success state and redirecting from the client (below) sidesteps that.return{ error:null, success:true};}catch(e){return{ error:{ _form:['Failed to create post. Please try again.']}, success:false};}}
// app/actions/posts.ts'use server';import{ after }from'next/server';exportasyncfunctionpublishPost(postId:string){const session =awaitauth();if(!session?.user)thrownewError('Unauthorized');const post =await db.posts.update({ where:{ id: postId, authorId: session.user.id }, data:{ published:true, publishedAt:newDate()},});revalidateTag('posts');// These run after the response is sent — don't block the userafter(async()=>{awaitsendPublishNotificationEmail(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};}
// This form works with or without JavaScriptexportdefaultfunctionSubscribeForm(){asyncfunctionsubscribe(formData:FormData){'use server';const email = formData.get('email')asstring;awaitaddSubscriber(email);redirect('/subscribed');}return(<formaction={subscribe}><inputname="email"type="email"required/><buttontype="submit">Subscribe</button></form>);}
'use server';exportasyncfunctiongetPostPreview(content:string):Promise<{ html:string}>{const html =awaitrenderMarkdown(content);return{ html };}