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.
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.
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.
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:
// app/actions/posts.ts 'use server';
import { auth } from '@/lib/auth';
// ❌ Don't assume the caller is authenticated export async function deletePostUnsafe(postId: string) { await db.posts.delete({ where: { id: postId } }); }
// ✅ Always verify inside the action export async function deletePost(postId: string) { 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');
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:
// ❌ 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');
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.
// components/CreatePostForm.tsx 'use client';
import { useActionState } from 'react'; import { createPost } from '@/app/actions/posts';
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 }; } }
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.
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.
'use client';
import { useOptimistic, useTransition } from 'react'; import { toggleLike } from '@/app/actions/posts';
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.
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.
// 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');
// 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.
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.
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.
// 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'); }
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.
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.
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.
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.