Next.js 16 Server Actions: The Bugs That Only Show Up in Production
- Author
- Shubhra Dev
- Date
- Reading time
- 28 min read
- Difficulty
- Advanced
On this page
Server Actions felt familiar by then. I'd used them in demos, tutorials, side projects. But production was different. My first real deployment was a contact form. Three fields, one button. I rushed it. Deployed it. Next morning: a Slack message saying the form broke when someone submitted it.
What actually happened: the action ran, the data saved, but the UI froze on a spinning button because I had wired pending correctly for the happy path and completely ignored what happened when the action threw. The button stayed disabled forever. The user submitted again. Got a duplicate. Then submitted again. Got three entries in the database.
I got that wrong. Here's everything I wish I'd known before I deployed it.
Server Actions are one of the best DX improvements Next.js has shipped. No API route. No manual fetch. No client state to juggle. Write an async function, add 'use server', attach it to a form. It just works. The code lives next to the component. Clean.
Until it isn't. Network requests timeout. Databases reject inserts. Users double-click submit buttons. Redirect timing matters. updateTag and revalidateTag don't do the same thing. Error boundaries don't catch what you think they catch. You'll hit all of this eventually, and it's much better to understand it before it breaks in front of real users.
This is Part 9 of the Next.js Deep Dive series. The most relevant earlier parts are Part 6 (the
use cachedirective and how cache invalidation works in Next.js 16), Part 7 (where Server Actions fit into the performance picture), and Part 8 (the three-layer auth model. proxy.ts, Server Components, and data layer scoping).
What Server Actions Actually Are in Next.js 16
The official docs call them Server Functions at the broader level, and Server Actions specifically when they're used for mutations: form submissions, button clicks that change data, optimistic updates. The term Server Action is the one you'll see in most tutorials including this one, but it helps to know the distinction because the docs use both terms and they're not always interchangeable.
Here's the part that matters: Server Actions are public HTTP endpoints. When you write an inline async function with 'use server' inside a Server Component, Next.js gives that function a unique, encrypted ID and exposes it over a POST endpoint. The encryption protects against tampering with the closure variables, but it doesn't protect against someone calling the endpoint directly with a valid ID.
The docs are explicit: always verify authentication and authorization inside every Server Function, even if the form is only rendered on an authenticated page. The rendering context doesn't protect the action.
In Next.js 16, the Server Function uses POST only. When it's attached to a <form> via the action prop, it receives the form's FormData automatically. When called from an event handler, you pass data manually.
Note: React dispatches and awaits Server Actions one at a time by design. If a user clicks a button that fires an action, and clicks it again while the first is still running, the second click queues and waits for the first to finish. This is an implementation detail that may change in future React versions, but right now it's how it works. You can't fire two Server Actions in parallel from the same client component. For parallel data operations, those go inside a single Server Function or in Route Handlers.
The Right Mental Model for Errors
Before writing a single line of code, get this distinction clear. It determines everything about how you handle errors in Server Actions.
Expected errors are things that can happen during normal operation. Validation failures. A username that's already taken. A payment that gets declined. These aren't surprises. You model them as return values, not exceptions. The action returns an object with an error message, the useActionState hook surfaces that object to the client, and your form renders the message.
Uncaught exceptions are real bugs or infrastructure failures. The database is down. An external API times out. A function that should never throw, throws. These get handled by throwing from the action, which surfaces them to the nearest error boundary.
The official guidance: for expected errors, avoid try/catch and avoid throwing. Return the error as part of the state object. For unexpected errors, throw, and let the error boundary take it.
This matters because the user experience is completely different. A form validation error should show next to the field that failed. An infrastructure failure should replace the component with a "something went wrong" UI with a retry button. Different failure modes, different handling.
I got this wrong. I threw an expected error from a validation check, it bypassed useActionState, hit the error boundary, and the user got a crash screen when all they needed was a message saying the email was already registered. Don't do what I did.
Loading States: useActionState vs useFormStatus
Two hooks handle pending states. They do different things and you'll often use both.
useActionState (from react) wraps your Server Action and gives you three things: the current state, the wrapped action to pass to the form, and a pending boolean. Use it when you need both the state returned by the action (errors, success messages) and the pending indicator in the same component.
useFormStatus (from react-dom) is a hook you use in a child component rendered inside a <form>. It reads the pending state of the nearest parent form. Use it when you want to keep your submit button in a separate component, which keeps forms clean and the button reusable.
The rule: use useActionState when you need to read what the action returned. Use useFormStatus when you just need to know if the form is submitting and you want that logic in a child component. In a real form you'll typically use useActionState at the form level for error state, then either pass pending down to the button or extract the button into a SubmitButton component that uses useFormStatus.
One thing that catches people coming from the Pages Router: useActionState was called useFormState in react-dom in earlier React versions. In React 19, it moved to the react package and was renamed. If you're on React 19 (which you are if you're on Next.js 16), import from react, not react-dom.
// React 19 / Next.js 16: correct import
import { useActionState } from "react";
// Old pattern (React 18 + react-dom): do not use this anymore
// import { useFormState } from 'react-dom'Step 1: The Action Signature That Makes Error Handling Work
When you use useActionState, the action signature changes. The action now receives prevState as its first argument, before formData. This is how the hook connects the previous state to the new render.
// app/actions.ts
"use server";
import { verifySession } from "@/lib/auth/session";
export type ActionState = {
errors?: {
name?: string[];
email?: string[];
message?: string[];
};
success?: boolean;
message?: string;
};
export async function submitContactForm(
prevState: ActionState,
formData: FormData,
): Promise<ActionState> {
// Auth check first. Every Server Action is a public POST endpoint.
// The form being rendered on an authenticated page does not protect this.
// See Part 8 of this series for the full three-layer auth pattern.
const session = await verifySession();
if (!session) {
return { message: "You must be signed in to submit this form." };
}
const name = formData.get("name") as string;
const email = formData.get("email") as string;
const message = formData.get("message") as string;
// Validation in Step 2
// Database call and cache invalidation below that
// Return errors as values, not thrown exceptions
return { success: true };
}The prevState parameter is the previous value of the state object. On the first render, it's the initialState you pass to useActionState. On subsequent calls, it's whatever the action returned last time. This lets you build on previous state if you need to, for example persisting non-errored fields when one field fails.
Keep the return type explicit. Typed return values mean TypeScript can verify every code path returns something the form can render. Without a type, you'll eventually have a code path that returns undefined and the form silently shows nothing.
Step 2: Zod Validation With Field-Level Errors
Zod is the standard for server-side validation in Next.js Server Actions. The key is safeParse rather than parse. safeParse never throws; it returns an object with success: true and data or success: false and error. That makes it perfect for the "return errors as values" pattern.
// app/actions.ts
"use server";
import { z } from "zod";
import { db } from "@/lib/db";
const contactSchema = z.object({
name: z
.string()
.min(2, "Name must be at least 2 characters")
.max(100, "Name is too long"),
email: z.string().email("Please enter a valid email address"),
message: z
.string()
.min(10, "Message must be at least 10 characters")
.max(2000, "Message must be under 2000 characters"),
});
export type ActionState = {
errors?: {
name?: string[];
email?: string[];
message?: string[];
};
success?: boolean;
message?: string;
};
export async function submitContactForm(
prevState: ActionState,
formData: FormData,
): Promise<ActionState> {
const rawData = {
name: formData.get("name"),
email: formData.get("email"),
message: formData.get("message"),
};
const validatedFields = contactSchema.safeParse(rawData);
if (!validatedFields.success) {
// Return field-level errors as values, no throw
return {
errors: validatedFields.error.flatten().fieldErrors,
};
}
// At this point TypeScript knows the data is valid
const { name, email, message } = validatedFields.data;
try {
await db.contactSubmissions.create({
data: { name, email, message },
});
} catch (error) {
// Database failure is an unexpected error
// We return it as a top-level message rather than throwing
// because throwing here would bypass useActionState and
// hit the error boundary instead, which is too aggressive
// for a database write failure on a contact form
console.error("Contact form submission failed:", error);
return {
message: "Something went wrong. Please try again in a moment.",
};
}
return { success: true };
}flatten().fieldErrors maps Zod's error structure to field-keyed arrays of strings. Each field gets an array because Zod can produce multiple error messages per field (for example, min and max both fail on an empty string). The form renders the first one, or all of them if you prefer.
On the catch block: there's a real design question about whether a database failure should return an error value or throw. It depends on the action. For a contact form, returning an error message is right because the form is still usable and the user can try again. For a critical transactional flow, throwing and letting the error boundary handle it might be more appropriate. The pattern above handles both cases.
Step 3: The Form That Shows Everything
This is where useActionState connects to the action and the state flows to the form elements. It also shows the form reset pattern, which comes up often: after a successful submission, you usually want to either show a success state (replacing the form entirely) or reset it so the user can submit again.
// app/contact/form.tsx
"use client";
import { useActionState, useRef } from "react";
import { submitContactForm, type ActionState } from "@/app/actions";
import { SubmitButton } from "./submit-button";
const initialState: ActionState = {};
export function ContactForm() {
const formRef = useRef<HTMLFormElement>(null);
const [state, formAction, pending] = useActionState(
submitContactForm,
initialState,
);
if (state.success) {
return (
<div
role="status"
aria-live="polite"
className="rounded-lg border border-emerald-200 bg-emerald-50 p-6 text-center"
>
<p className="text-emerald-800 font-medium">Message sent.</p>
<p className="mt-1 text-sm text-emerald-700">
You should hear back within one business day.
</p>
</div>
);
}
return (
<form ref={formRef} action={formAction} noValidate className="space-y-5">
{/* Top-level error message for non-field failures */}
{state.message && (
<p
role="alert"
aria-live="assertive"
className="rounded bg-red-50 p-3 text-sm text-red-700"
>
{state.message}
</p>
)}
<div className="space-y-1">
<label htmlFor="name" className="block text-sm font-medium">
Name
</label>
<input
id="name"
name="name"
type="text"
required
autoComplete="name"
aria-describedby={state.errors?.name ? "name-error" : undefined}
aria-invalid={state.errors?.name ? "true" : undefined}
className="w-full rounded border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-violet-500"
/>
{state.errors?.name && (
<p id="name-error" role="alert" className="text-sm text-red-600">
{state.errors.name[0]}
</p>
)}
</div>
<div className="space-y-1">
<label htmlFor="email" className="block text-sm font-medium">
Email
</label>
<input
id="email"
name="email"
type="email"
required
autoComplete="email"
aria-describedby={state.errors?.email ? "email-error" : undefined}
aria-invalid={state.errors?.email ? "true" : undefined}
className="w-full rounded border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-violet-500"
/>
{state.errors?.email && (
<p id="email-error" role="alert" className="text-sm text-red-600">
{state.errors.email[0]}
</p>
)}
</div>
<div className="space-y-1">
<label htmlFor="message" className="block text-sm font-medium">
Message
</label>
<textarea
id="message"
name="message"
required
rows={5}
aria-describedby={state.errors?.message ? "message-error" : undefined}
aria-invalid={state.errors?.message ? "true" : undefined}
className="w-full rounded border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-violet-500"
/>
{state.errors?.message && (
<p id="message-error" role="alert" className="text-sm text-red-600">
{state.errors.message[0]}
</p>
)}
</div>
<SubmitButton pending={pending} />
</form>
);
}A few things in this form that I missed the first time.
Form reset after success. The formRef is wired to the form element but the success state renders a different UI entirely, replacing the form. If you'd rather keep the form visible and reset it instead, call formRef.current?.reset() inside a useEffect that watches state.success. Either pattern works; it depends on your UX.
noValidate disables browser-native HTML5 validation. You want server-side validation to be the source of truth, with consistent error messages that you control. Browser validation fires before the action runs and displays native popups you can't style or translate. Turn it off.
aria-describedby and aria-invalid are not optional if accessibility matters. Screen readers announce form errors by reading the element pointed to by aria-describedby. Without it, a screen reader user submits the form and hears nothing useful. aria-invalid="true" signals the field has an error. These two together are the accessibility baseline for form validation.
aria-live="polite" on the success message and aria-live="assertive" on the error alert: polite means the reader finishes its current announcement before reading the update. Assertive interrupts immediately. Success messages can wait. Error alerts shouldn't. The distinction matters on slow networks where the pending state and the result state appear in quick succession.
role="alert" on error messages triggers an immediate announcement in most screen readers even if the element was already in the DOM. Without it, injecting an error into an existing <p> tag may not be announced at all.
Step 4: The Submit Button Component
Two ways to handle the button. Pass pending from useActionState directly, or use useFormStatus in a separate component.
Direct approach, when the button is in the same component as useActionState:
// Simple version: pending comes directly from useActionState
<button
type="submit"
disabled={pending}
className="w-full rounded bg-violet-600 px-4 py-2 text-sm font-medium text-white hover:bg-violet-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{pending ? "Sending..." : "Send message"}
</button>The useFormStatus version when you want a reusable component:
// app/contact/submit-button.tsx
"use client";
import { useFormStatus } from "react-dom";
export function SubmitButton({ pending }: { pending?: boolean }) {
// useFormStatus reads from the nearest parent <form>
// It must be used in a component that is a child of a <form>
// Using it at the same level as the form does not work
const { pending: formPending } = useFormStatus();
const isPending = pending ?? formPending;
return (
<button
type="submit"
disabled={isPending}
aria-disabled={isPending}
className="w-full rounded bg-violet-600 px-4 py-2 text-sm font-medium text-white hover:bg-violet-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{isPending ? (
<span className="flex items-center justify-center gap-2">
<span
className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"
aria-hidden="true"
/>
<span>Sending...</span>
</span>
) : (
"Send message"
)}
</button>
);
}aria-hidden="true" on the spinner keeps the animation from being announced by screen readers. The visible text "Sending..." is enough.
Common mistake with useFormStatus: using it in the same component that renders the <form>. It has to be inside a child component rendered within the form's JSX. If you put useFormStatus at the same level as the <form> tag, it'll always return pending: false.
Step 5: Cache Invalidation After a Mutation
This is where Next.js 16 introduced real changes that break apps migrated from older versions. There are now four distinct cache functions and they do different things.
updateTag is the one you use in Server Actions when a user makes a change and needs to see that change reflected immediately. It expires the cache tag right now and the next request blocks until fresh data arrives. Read-your-writes semantics. Use this for user-triggered mutations. Server Actions only.
revalidateTag is for stale-while-revalidate. It marks the tag as stale, but the next request still gets cached data while fresh data is fetched in the background. The request after that gets the fresh version. Use this for background sync, webhooks, admin updates where a slight delay is acceptable. Works in both Server Actions and Route Handlers.
revalidatePath invalidates a specific URL path rather than a cache tag. Use it when you don't have tags set up or want to invalidate everything at a route regardless of tagging.
refresh (from next/cache) refreshes the client router from within a Server Action. Use it when you want to trigger a re-render of the current page without busting specific cache tags.
// app/actions.ts
"use server";
import { revalidatePath, revalidateTag, updateTag } from "next/cache";
import { db } from "@/lib/db";
// User updates their profile: they need to see their change immediately
export async function updateProfile(
prevState: ActionState,
formData: FormData,
): Promise<ActionState> {
// ... auth check, validation ...
await db.users.update({
where: { id: userId },
data: { name: formData.get("name") as string },
});
// updateTag: user sees their change on the next render, not a stale version
// Only available in Server Actions, not Route Handlers
updateTag(`user-${userId}`);
return { success: true };
}
// Admin publishes a blog post: slight delay is acceptable for readers
export async function publishPost(
prevState: ActionState,
formData: FormData,
): Promise<ActionState> {
// ... auth check, admin role check, validation ...
await db.posts.update({
where: { id: formData.get("postId") as string },
data: { published: true },
});
// revalidateTag with 'max' profile: stale-while-revalidate
// Readers may see the old version briefly while fresh content loads
revalidateTag("posts", "max");
return { success: true };
}
// Simple path invalidation when you do not have tags
export async function deleteComment(
prevState: ActionState,
formData: FormData,
): Promise<ActionState> {
// ... auth, ownership check ...
const postId = formData.get("postId") as string;
await db.comments.delete({
where: { id: formData.get("commentId") as string },
});
// Invalidate the specific post page that showed this comment
revalidatePath(`/blog/${postId}`);
return { success: true };
}The production bug that comes from getting updateTag vs revalidateTag wrong: a user updates their profile name, gets the success state, navigates back to their profile, and still sees the old name. That's revalidateTag with stale-while-revalidate where you needed updateTag for immediate read-your-writes. It seems fine in development because dev mode doesn't cache at all.
Also: revalidateTag in Next.js 16 requires the second argument (a cacheLife profile like 'max', or an inline { expire: number } object). The single-argument form is deprecated. If you're on a migrated Next.js 16 project and your revalidateTag calls only have one argument, they're using deprecated behavior. Update them now: revalidateTag('tag', 'max'). The { expire: 0 } form is specifically for Route Handlers when an external system like a webhook needs to force a cache bust, not the standard pattern in Server Actions.
Step 6: Redirecting After a Successful Action
Redirect after mutation is a common pattern. The user submits a form, the action succeeds, they land on the result page. Two things about redirect will bite you if you don't know them upfront.
// app/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
export async function createPost(
prevState: ActionState,
formData: FormData,
): Promise<ActionState> {
// ... auth, validation ...
const post = await db.posts.create({
data: {
title: formData.get("title") as string,
content: formData.get("content") as string,
},
});
// Revalidate before redirecting.
// redirect() throws a control flow exception that stops execution.
// Anything after redirect() will not run.
revalidatePath("/posts");
// or: updateTag('posts') if the post list uses cache tags
redirect(`/posts/${post.id}`);
}redirect in Next.js throws a special control-flow exception that the framework catches. The consequence: any code after redirect() never executes. If you call revalidatePath after redirect, the cache won't be invalidated. Put revalidation calls first.
The other thing: do not put redirect inside a try/catch without re-throwing. I spent two hours debugging a redirect that wouldn't fire. The action succeeded. The database updated. The user stayed on the same page. I added console logs. I checked the network tab. I restarted the dev server. Then I read the docs and realized redirect throws, and my catch block was eating it. Two hours for a one-line fix.
// This will break: redirect is swallowed by catch
try {
await db.posts.create(...)
redirect('/posts') // throws, caught below, redirect never happens
} catch (error) {
console.error(error) // catches the redirect exception too
}
// Correct: mutation and revalidation inside try, redirect outside
try {
await db.posts.create(...)
revalidatePath('/posts')
} catch (error) {
return { message: 'Failed to create post. Please try again.' }
}
redirect('/posts') // outside the try, runs only if no error was returnedStep 6.5: Optimistic UI With useOptimistic
useOptimistic is a React 19 hook that lets you show an immediate UI update while the Server Action is still running. The user clicks "like", the count goes up immediately, and if the action fails, React rolls back the optimistic state automatically. No manual rollback logic needed.
It's the right tool when the mutation is low-stakes and the success rate is high. A like button, a follow toggle, marking a task complete. If the user is unlikely to see a failure and the delay is noticeable, useOptimistic makes the experience feel instant.
// app/components/like-button-optimistic.tsx
"use client";
import { useOptimistic, useTransition } from "react";
import { toggleLike } from "@/app/actions";
type LikeState = { liked: boolean; count: number };
export function LikeButton({
postId,
initialLiked,
initialCount,
}: {
postId: string;
initialLiked: boolean;
initialCount: number;
}) {
const [isPending, startTransition] = useTransition();
const [optimisticState, addOptimistic] = useOptimistic<LikeState, boolean>(
{ liked: initialLiked, count: initialCount },
(currentState, newLiked) => ({
liked: newLiked,
count: newLiked ? currentState.count + 1 : currentState.count - 1,
}),
);
function handleClick() {
const newLiked = !optimisticState.liked;
startTransition(async () => {
// Show optimistic state immediately
addOptimistic(newLiked);
// Run the actual Server Action
await toggleLike(postId);
// If toggleLike throws, React rolls back optimisticState automatically
});
}
return (
<button
onClick={handleClick}
disabled={isPending}
aria-label={optimisticState.liked ? "Unlike this post" : "Like this post"}
aria-pressed={optimisticState.liked}
className="flex items-center gap-1 text-sm disabled:opacity-60"
>
<span aria-hidden="true">{optimisticState.liked ? "♥" : "♡"}</span>
<span>{optimisticState.count}</span>
</button>
);
}Two things worth knowing about useOptimistic:
It only applies during the transition. Once the Server Action resolves (success or failure), React uses the real state from the server. If the action fails, the optimistic state is discarded and the UI reverts. This is the automatic rollback the docs describe.
It requires an Action context. The addOptimistic call must happen inside startTransition (or a form action prop, which wraps it automatically) for the rollback behavior to work. If you call the setter outside an Action, React shows a warning and the optimistic state briefly renders before reverting.
The difference between this and the like button in Step 8: the Step 8 version uses useActionState which waits for the server round trip before updating the UI. useOptimistic updates immediately and trusts the server to confirm. Use useOptimistic when perceived performance matters more than guaranteed accuracy at the moment of interaction.
Step 7: Error Boundaries for Unexpected Failures
Error boundaries catch uncaught exceptions thrown from Server Actions. In Next.js 16, you set these up with error.tsx files at the route segment level. One file, scoped to its directory.
// app/dashboard/error.tsx
"use client";
import { useEffect } from "react";
export default function ErrorPage({
error,
unstable_retry,
reset,
}: {
error: Error & { digest?: string };
unstable_retry: () => void;
reset: () => void;
}) {
useEffect(() => {
// Send to your error monitoring service
// Sentry, Datadog, whatever you use
console.error("Dashboard error:", error);
}, [error]);
return (
<div className="flex min-h-64 flex-col items-center justify-center gap-4 p-8 text-center">
<h2 className="text-lg font-semibold">Something went wrong</h2>
<p className="text-sm text-gray-600">
An unexpected error occurred. We have been notified.
</p>
<div className="flex gap-2">
<button
onClick={() => unstable_retry()}
className="rounded bg-violet-600 px-4 py-2 text-sm text-white hover:bg-violet-700"
>
Try again
</button>
<button
onClick={() => reset()}
className="rounded border border-violet-600 px-4 py-2 text-sm text-violet-600 hover:bg-violet-50"
>
Reset
</button>
</div>
</div>
);
}unstable_retry rerenders the segment, which re-runs the Server Component that rendered it. The unstable_ prefix signals this API may change in a future Next.js release. It works in Next.js 16, but watch the release notes.
The digest field is a hash that maps to a server-side error log entry. Next.js intentionally doesn't expose raw error messages to the client in production to avoid leaking implementation details. Use the digest to look up the full error in your server logs. This is why error.message is often empty or generic in production error boundaries.
For more granular error recovery at the component level without creating a full route segment file, unstable_catchError from next/error lets you wrap individual components:
// app/components/dangerous-widget.tsx
"use client";
import { unstable_catchError as catchError, type ErrorInfo } from "next/error";
function WidgetError(
props: { title: string },
{ error, unstable_retry }: ErrorInfo,
) {
return (
<div className="rounded border border-red-200 bg-red-50 p-4">
<p className="text-sm font-medium text-red-700">{props.title}</p>
<button
onClick={() => unstable_retry()}
className="mt-2 text-xs text-red-600 underline"
>
Try again
</button>
</div>
);
}
export default catchError(WidgetError);Then use it as a wrapper:
import DangerousWidgetError from "./dangerous-widget";
export function Dashboard() {
return (
<DangerousWidgetError title="Widget failed to load">
<DangerousWidget />
</DangerousWidgetError>
);
}Error boundaries don't catch errors inside event handlers. An error thrown inside an onClick that's not inside a startTransition won't reach the error boundary. It surfaces as an unhandled error in the browser. If you need error boundary coverage for event handler errors, wrap the code in startTransition.
Step 8: Non-Form Server Actions
Not every Server Action is attached to a form. Like buttons, delete confirmations, keyboard shortcuts that trigger mutations. These use event handlers and startTransition.
// app/posts/post-card.tsx
"use client";
import { useState, useTransition } from "react";
import { deletePost } from "@/app/actions";
export function PostCard({ postId, title }: { postId: string; title: string }) {
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
function handleDelete() {
if (!confirm(`Delete "${title}"? This cannot be undone.`)) return;
setError(null);
startTransition(async () => {
try {
await deletePost(postId);
// If deletePost calls revalidatePath or updateTag inside,
// the list will update automatically after this resolves
} catch {
// Errors thrown inside startTransition bubble to the error boundary
// but you can also catch them manually and update local state
setError("Failed to delete post. Please try again.");
}
});
}
return (
<article className="rounded border p-4">
<h2 className="font-medium">{title}</h2>
{error && (
<p role="alert" className="mt-2 text-sm text-red-600">
{error}
</p>
)}
<button
onClick={handleDelete}
disabled={isPending}
aria-disabled={isPending}
className="mt-3 text-sm text-red-600 hover:text-red-700 disabled:opacity-50"
>
{isPending ? "Deleting..." : "Delete"}
</button>
</article>
);
}useTransition is what you use when you're not wrapping a form. It gives you isPending and startTransition. The action runs inside startTransition, which tells React this is a non-urgent transition and lets the UI stay interactive while it's running.
Errors not caught manually inside the startTransition callback bubble up to the nearest error boundary. That's often the right behavior. But sometimes, like the delete button above, you want to catch the error and show it inline rather than crashing the whole component tree. Both patterns are valid. The choice depends on how recoverable the error is.
For a non-optimistic like button where you want the server to confirm before updating the count, useActionState with startTransition works like this. (If you want immediate UI feedback without waiting, use useOptimistic from Step 6.5 instead.)
// app/components/like-button.tsx
"use client";
import { useActionState, startTransition } from "react";
import { toggleLike } from "@/app/actions";
export function LikeButton({
postId,
initialLiked,
initialCount,
}: {
postId: string;
initialLiked: boolean;
initialCount: number;
}) {
const [state, action, pending] = useActionState(toggleLike, {
liked: initialLiked,
count: initialCount,
});
return (
<button
onClick={() => startTransition(() => action(postId))}
disabled={pending}
aria-label={state.liked ? "Unlike this post" : "Like this post"}
aria-pressed={state.liked}
className="flex items-center gap-1 text-sm disabled:opacity-60"
>
<span aria-hidden="true">{state.liked ? "♥" : "♡"}</span>
<span>{state.count}</span>
</button>
);
}The aria-pressed attribute communicates toggle button state to screen readers. A button without aria-pressed reads as a regular button. With aria-pressed="true", a screen reader announces it as pressed (or activated). This is the correct ARIA pattern for toggle buttons.
The Edge Cases That Break Production Apps
These are the ones that don't show up in tutorials but do show up in production.
Double submissions. The user clicks submit, nothing appears to happen for 500ms because the network is slow, so they click again. Two actions queued. React serializes them, so they run sequentially, and your database gets two identical inserts. Fix: disabled={pending} on the submit button. If the button isn't disabled during the pending state, you'll get duplicates. This is also why you want idempotency keys for critical mutations, but that's a database design topic for another day.
The frozen UI bug. The action throws, the error boundary is too far up the tree, or the action is called outside of useActionState. The pending state never resolves to false because useActionState never gets the error response. The button stays disabled forever. Make sure every code path through the action either returns a state object (for expected errors handled by useActionState) or throws cleanly (for unexpected errors that reach the error boundary and trigger unstable_retry to reset pending).
redirect inside try/catch. Covered in Step 6, but worth repeating because it's the most common redirect bug: redirect() throws. If it's inside a try/catch, the exception is swallowed. The redirect silently doesn't happen, the action appears to succeed, the user stays on the same page. Two hours, one-line fix. Put redirect outside the try block.
revalidateTag vs updateTag in production but not in development. Dev mode doesn't cache. Both APIs appear identical in local dev because there's no cache to bust. In production, revalidateTag with stale-while-revalidate means the user who triggered the mutation may see stale data for a moment. If your feature needs immediate consistency, use updateTag.
Cookies and headers inside Server Actions. cookies() and headers() from next/headers are async in Next.js 16. Missing the await returns a Promise object instead of the cookie store, and every call on it will fail silently or return undefined. No error. No warning. Just wrong values.
// Correct in Next.js 16
const cookieStore = await cookies();
const sessionCookie = cookieStore.get("session");
// Missing await: cookieStore is a Promise, .get() is undefined
const cookieStore = cookies(); // wrongSerialization limits on arguments. Arguments you close over in inline Server Action functions must be serializable. Plain objects, strings, numbers, arrays. Class instances, functions, DOM nodes, Symbols will throw at runtime when the framework tries to encrypt the closure. The error message isn't helpful. Know the constraint before you hit it.
File uploads. formData.get('avatar') returns a File object when the input is type="file". No built-in limit enforcement inside the Server Action itself. Next.js 16 has serverActions.bodySizeLimit (default 1MB) that caps the total request body.
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
serverActions: {
bodySizeLimit: "4mb", // Raise the limit for file uploads
allowedOrigins: ["yoursite.com", "www.yoursite.com"],
},
},
};
export default nextConfig;allowedOrigins is a CSRF control. Server Actions check the Origin header on every POST. If the origin isn't in the list, the request is rejected. The default is the deployment URL. If your app runs on multiple domains (custom domain plus a Vercel preview URL), add them both.
Actions in Suspense boundaries. If a Server Action causes the component to re-suspend, the UI shows the Suspense fallback again. Correct behavior, but surprising if you expect an inline loading state. If you want inline pending feedback rather than the whole component reverting to a skeleton, manage it with pending from useActionState.
Before You Ship: The Checklist
These all worked in local dev. Every single one has broken in production.
Every action has an auth check. Not just the form that renders it, the action itself. Server Actions are public HTTP endpoints. Test this by calling the action directly from Insomnia or Postman without a valid session. It should reject.
Test with JavaScript disabled. A form wired to a Server Action via the action attribute works without JavaScript because it submits as a standard HTML form. Test that the submit works and returns something useful when JS is off.
Test the error states, not just the success path. Deliberately trigger a validation error. Deliberately make the database call fail. Verify the button comes back to its enabled state. If it stays disabled, there's a code path that's not resolving pending.
Check revalidatePath / updateTag calls are before redirect. Confirm the order on every action that does both. Anything after redirect() doesn't run.
Verify the allowedOrigins config in production. If your app gets a 403 on Server Action POSTs in production but not in development, the Origin header isn't in the allowed list.
Run next build and check the output. Next.js outputs the encrypted action endpoint IDs in the build. It confirms the actions are being bundled and their IDs are encrypted per-build.
Check revalidateTag calls have the second argument. Single-argument revalidateTag calls are deprecated in Next.js 16. Update them now: revalidateTag('tag', 'max').
Quick Reference
// Action signature with useActionState
export async function myAction(
prevState: ActionState,
formData: FormData,
): Promise<ActionState> {
'use server'
// 1. Auth check
// 2. Validate with Zod safeParse
// 3. Return { errors } for validation failures
// 4. Try the mutation
// 5. Return { message } for unexpected errors
// 6. updateTag / revalidatePath before redirect
// 7. Return { success: true } or redirect()
}
// Client component
'use client'
const [state, formAction, pending] = useActionState(myAction, initialState)
// state.errors => field-level validation errors
// state.message => top-level non-field error
// state.success => show success UI
// pending => disable button, show spinner
// Submit button (same component)
<button type="submit" disabled={pending}>
{pending ? 'Saving...' : 'Save'}
</button>
// Submit button (child component using useFormStatus)
'use client'
const { pending } = useFormStatus() // must be inside a <form> child
// Cache invalidation in Server Actions
updateTag('tag') // read-your-writes, immediate expiry, SA only
revalidateTag('tag', 'max') // stale-while-revalidate, SA + Route Handlers
revalidatePath('/some-path') // invalidate a URL, SA + Route Handlers
refresh() // refresh client router from within SA only
// Optimistic UI
const [optimisticState, addOptimistic] = useOptimistic(initialState, reducer)
// Call addOptimistic(newValue) inside startTransition → auto-rollback on error
// Redirect
revalidatePath('/posts') // before redirect
redirect('/posts') // after, outside try/catch
// Error boundary
// app/route-segment/error.tsx
'use client'
export default function ErrorPage({ error, unstable_retry }) { ... }
// Non-form action
const [isPending, startTransition] = useTransition()
startTransition(async () => { await myAction(args) })
// Cookies / headers in Next.js 16 (always await)
const cookieStore = await cookies()
const headerStore = await headers()Continue Learning in This Series
This is Part 9 of the Next.js Deep Dive series. Here's where the earlier parts connect to what you just read.
Part 6: Next.js 16 Cache Components covers use cache, cacheTag, cacheLife, and how the caching system in Next.js 16 works end to end. The updateTag and revalidateTag calls in Step 5 of this tutorial are the mutation side of the cache system introduced in Part 6.
Part 7: Performance Optimization Checklist covers the full performance optimization checklist for Next.js 16. The Server Action patterns in this tutorial, including useOptimistic for instant UI feedback, connect to the interaction performance and caching tiers covered there.
Part 8: 3-Layer Authentication covers the foundation: proxy.ts for network boundary auth, Server Components for render-time checks, and the data layer for query scoping. This tutorial builds on that foundation for the Server Action layer specifically.
Further Reading
Next.js: Mutating Data. The official guide to Server Functions and Server Actions in Next.js, including forms, event handlers, and the pending state pattern.
Next.js: Error Handling. The complete error handling reference including error.tsx, global-error.tsx, and unstable_catchError.
Next.js: How to Create Forms with Server Actions. The official forms guide covering validation, pending states, useActionState, useFormStatus, and optimistic updates.
Next.js: Revalidating Data. Covers updateTag, revalidateTag, revalidatePath, and when to use each.
React: useActionState. The React 19 reference for useActionState, including the signature change when upgrading from useFormState.
React: useOptimistic. The React 19 reference for useOptimistic, including how automatic rollback works and the reducer pattern for computing optimistic state.
