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>
  );
}
Throwing versus returning a typed failure result Two paths from a submitted form. The upper path, labelled throw, leads through a digest box to an error route boundary and ends with the form lost. The lower path, labelled return result, leads to the same form re-rendered with inline field errors and all submitted values preserved. form submit 15 fields typed throw return digest only message stripped error.tsx replaces the route form and its values are gone typed result kind + values echoed back same form, inline errors nothing retyped, focus preserved

Step-by-step #

  1. Classify before you code. Validation, conflict and upstream-unavailable are outcomes; a null dereference is a bug. Outcomes return, bugs throw.
  2. Echo the values. values in every failure branch is what keeps the form populated. This is the single highest-value line in the file.
  3. Rethrow control-flow signals. redirect() and notFound() work by throwing. A catch that does not rethrow them converts a successful navigation into a silent failure.
  4. 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.
  5. Revalidate narrowly. revalidatePath('/orders') refreshes what changed. revalidatePath('/', 'layout') refetches the world and turns one mutation into a page-wide reload.
  6. Announce failures. role="alert" on the message and aria-invalid/aria-describedby on 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.

Optimistic removal with a failed action A timeline with four markers: click, optimistic removal, action returns failure, and React restores server state. Below it a dashed branch labelled manual undo shows a second correction producing a visible flicker, marked as the mistake to avoid. click item hidden optimistically action returns failure server state restored React discards optimistic state automatically — one correction, no flicker manual undo (avoid): second correction → visible flicker announce the failure instead of re-applying state the framework already reverted Revalidation scope after a mutation Two page diagrams. The narrow case refreshes only the orders segment, leaving header, sidebar and unrelated panels untouched. The layout-wide case marks every segment as refetched. revalidatePath('/orders') revalidatePath('/', 'layout') header — cached orders — refetched sidebar — cached header — refetched orders — refetched sidebar — refetched one mutation should not turn into a full-page reload of every server component

Verification #

  1. Submit invalid data. Inline messages appear, every other field keeps its value, and no error.tsx is involved.
  2. Force a declined payment. A message renders above the form, the submit button re-enables, and the order is not created.
  3. Force a genuine throw. Confirm error.tsx renders, the digest in the browser matches a server log line, and the log has the full stack.
  4. Check redirect still works. Place a valid order inside the try; the navigation must happen, proving isRedirectError rethrow is correct.
  5. 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.