This page extends the routing-error patterns in Next.js & Nuxt Routing Error Pages to mutations, where a failure costs the user work rather than just a view.
The exact problem #
A checkout form posts to a server action. The payment provider rejects the card. The action throws. In production the client receives An error occurred in the Server Components render… digest: "1174928835", the nearest error.tsx takes over the route, and the user’s fifteen fields of typed address data are gone.
Every part of that is avoidable. The rejection was an expected outcome, not a bug — it should have been data, not an exception. And even for genuine bugs, the form’s values should survive.
Zero-to-working action #
// app/checkout/actions.ts
'use server';
import { redirect } from 'next/navigation';
import { revalidatePath } from 'next/cache';
export type ActionResult =
| { ok: true; orderId: string }
| { ok: false; kind: 'validation'; fieldErrors: Record<string, string>; values: FormValues }
| { ok: false; kind: 'conflict'; message: string; values: FormValues }
| { ok: false; kind: 'unavailable'; message: string; values: FormValues };
export async function placeOrder(_prev: ActionResult | null, formData: FormData): Promise<ActionResult> {
const values = readValues(formData); // plain object, echoed back on failure
const parsed = OrderSchema.safeParse(values);
if (!parsed.success) {
// Expected outcome → data, not an exception.
return { ok: false, kind: 'validation', fieldErrors: flatten(parsed.error), values };
}
try {
const order = await createOrder(parsed.data);
revalidatePath('/orders'); // narrow: only what changed
redirect(`/orders/${order.id}`); // throws a control-flow signal
} catch (err) {
// redirect() and notFound() MUST keep propagating.
if (isRedirectError(err) || isNotFoundError(err)) throw err;
if (err instanceof PaymentDeclined) {
return { ok: false, kind: 'conflict', message: err.userMessage, values };
}
if (err instanceof UpstreamTimeout) {
return { ok: false, kind: 'unavailable', message: 'The payment service is not responding.', values };
}
// Genuine bug: log it fully here, because the client will only get a digest.
logger.error({ scope: 'action:placeOrder', err, digest: digestOf(err) });
throw err;
}
}
// app/checkout/CheckoutForm.tsx
'use client';
import { useActionState } from 'react';
export function CheckoutForm({ initial }: { initial: FormValues }) {
const [result, formAction, pending] = useActionState(placeOrder, null);
const values = result && !result.ok ? result.values : initial; // never blank
return (
<form action={formAction} noValidate>
{result && !result.ok && result.kind !== 'validation' && (
<p role="alert">{result.message}</p>
)}
<label htmlFor="address">Address</label>
<input id="address" name="address" defaultValue={values.address}
aria-invalid={Boolean(result && !result.ok && result.kind === 'validation' && result.fieldErrors.address)}
aria-describedby={result && !result.ok && result.kind === 'validation' && result.fieldErrors.address ? 'address-err' : undefined} />
{result && !result.ok && result.kind === 'validation' && result.fieldErrors.address && (
<p id="address-err" role="alert">{result.fieldErrors.address}</p>
)}
<button type="submit" disabled={pending}>{pending ? 'Placing order…' : 'Place order'}</button>
</form>
);
}
Step-by-step #
- Classify before you code. Validation, conflict and upstream-unavailable are outcomes; a null dereference is a bug. Outcomes return, bugs throw.
- Echo the values.
valuesin every failure branch is what keeps the form populated. This is the single highest-value line in the file. - Rethrow control-flow signals.
redirect()andnotFound()work by throwing. Acatchthat does not rethrow them converts a successful navigation into a silent failure. - Log before the boundary strips it. The digest is useless without a matching server log line; write it inside the action where the real error still exists.
- Revalidate narrowly.
revalidatePath('/orders')refreshes what changed.revalidatePath('/', 'layout')refetches the world and turns one mutation into a page-wide reload. - Announce failures.
role="alert"on the message andaria-invalid/aria-describedbyon the fields means the failure is perceivable without sight, following the same rules as Building Accessible Error Fallback UI with ARIA Live Regions.
| Failure | Model as | Client behaviour |
|---|---|---|
| Schema validation failed | Returned validation result |
Inline field messages, values retained |
| Payment declined | Returned conflict result |
Message above the form, retry enabled |
| Upstream timeout | Returned unavailable result |
Message plus a retry that does not resubmit twice |
| Not authenticated | redirect('/login?next=…') |
Navigation, with the return path preserved |
| Record no longer exists | notFound() |
The route’s not-found.tsx |
| Null dereference in the action | Thrown | error.tsx, digest logged server-side |
Optimistic UI and rollback #
useOptimistic makes a mutation feel instant; a failed action must undo it without leaving the list flickering.
'use client';
export function CartList({ items }: { items: CartItem[] }) {
const [optimistic, addOptimistic] = useOptimistic(items, applyIntent);
const [result, formAction] = useActionState(removeItem, null);
useEffect(() => {
if (result && !result.ok) {
// React discards optimistic state when the action settles, so the list
// already shows the server truth — announce the failure instead of
// trying to "undo" manually, which double-corrects.
announce(result.message);
}
}, [result]);
return (
<ul>
{optimistic.map((item) => (
<li key={item.id}>
{item.name}
<form action={(fd) => { addOptimistic({ type: 'remove', id: item.id }); return formAction(fd); }}>
<input type="hidden" name="id" value={item.id} />
<button type="submit">Remove</button>
</form>
</li>
))}
</ul>
);
}
The mistake to avoid is manually reverting after a failure. React already drops optimistic state once the action resolves, so a hand-written undo applies a second correction and the item flickers back and forth.
Verification #
- Submit invalid data. Inline messages appear, every other field keeps its value, and no
error.tsxis involved. - Force a declined payment. A message renders above the form, the submit button re-enables, and the order is not created.
- Force a genuine throw. Confirm
error.tsxrenders, the digest in the browser matches a server log line, and the log has the full stack. - Check redirect still works. Place a valid order inside the
try; the navigation must happen, provingisRedirectErrorrethrow is correct. - Test optimistic rollback. Remove an item with the action forced to fail; the item must return exactly once, with no double flicker.
A final note on typing: keeping ActionResult in a module both the action and the form import means a new failure kind cannot be added without the client being forced to handle it. That single shared type is what stops the client and the server drifting into disagreement about which outcomes exist, which is the failure mode that produces a form silently ignoring a rejection it was never told about.
Frequently asked questions #
Why does my server action error show as an opaque digest in production?
Next.js strips messages crossing the server–client boundary so internals cannot leak. Good for bugs, bad for expected failures — return validation and conflict outcomes as data instead of throwing.
Why does my form clear itself when the action fails?
Because the form re-renders from scratch and nothing carried the submitted values back. Include them in the failure result and use them as defaultValue.
Is it safe to wrap a server action body in try/catch?
Only if you rethrow redirect and notFound, which are implemented as thrown signals. A blanket catch turns a successful redirect into a swallowed error.
Related #
- Next.js & Nuxt Routing Error Pages — where route-level error files fit
- Next.js 14 App Router error.tsx vs not-found.tsx Strategies — choosing the right route-level file
- Form State Recovery & Multi-Step Wizards — surviving the failures that a re-render cannot repair