TLDR: Next.js production masks thrown server-action errors behind useless digest hashes. Return errors as typed data — ActionResult with ok() and fail() helpers — and you get real messages in your toasts every single time.

the setup

I was doing a WIN-framework audit on my internal webinar project management app (Next.js 16 + Supabase, my Postgres-as-a-service backend) — before rolling it out to the team.

I thought the error handling was fine.

Reader, it was not fine.

what the audit actually found

The toast library (sonner) was mounted at the app root. Every surface had SWR (my client-side data-fetching layer) wired up. Everything looked right.

But there were ZERO toast() calls anywhere in the codebase.

SWR error fields — ignored. Supabase write errors — swallowed in every server component. .update() null-error checks — missing.

Decorative error handling.

The UI looked hardened. It was ABSOLUTELY not.

the real problem with throwing

Here's the thing I didn't fully appreciate about Next.js server actions (the server-side functions that handle mutations and form submissions) in production: if you throw from one, the user does not see your message.

They see something like Error: [digest: 3249812398].

Cryptic. Useless. Completely opaque to anyone trying to debug it.

I'd been throwing errors — throw new Error("Invalid input"), the standard move — thinking that was handling them. It was not. And I had zero idea, because it only surfaces this way in prod.

the pattern that actually works

I pulled everything into src/lib/validation.ts. The core of it:

type ActionResult = { ok: true } | { ok: false; error: string }
const ok = (): ActionResult => ({ ok: true })
const fail = (msg: string): ActionResult => ({ ok: false, error: msg })

Every server action now follows this exact loop:

  1. Validate with Zod (a TypeScript schema library) using .safeParse()not .parse(), which throws. On failure: return fail(firstIssue(parsed.error)) (a tiny helper that pulls the first readable message out of a Zod error object)
  2. Do the DB write and chain .select().single() so RLS failures (row-level security — Supabase's per-row auth checks) and CHECK constraint violations actually surface as a real error field instead of silently returning nothing
  3. Check errorreturn fail("something the user can act on")
  4. return ok() at the end

An ActionForm wrapper component handles the toast automatically — reads result.ok, fires toast.error(result.error) if not. You write the action; the feedback layer is handled.

the moment it earned its keep

The following week, in a sister supply chain app, I couldn't add a supplier role to a contact (contact #54).

Old behavior: digest hash. Zero information.

New behavior: real error message in the toast, immediately.

The Zod enum on the roles field was rejecting ["kol", "supplier"] because "kol" wasn't in its allowed values — a cross-app schema mismatch I hadn't caught.

Turns out 55 of 75 contacts in that app carried a "kol" role from a shared contacts table. Every single one of them was un-editable. 55 of 75. Blast radius discovered in minutes, not hours, because the error was readable.

why this matters to me

Invisible failures are the worst kind of failure. The Toaster was mounted. The errors were silent. Nobody knew.

The pattern doesn't add much code — ok(), fail(), one wrapper component, one helper file. But it means every failure now has a voice. And in prod, that's the difference between a five-minute fix and a two-hour archaeology dig.

P.S. .safeParse() over .parse() everywhere — this alone is worth the migration. The moment you stop throwing from validation, a whole category of opaque prod errors just… disappears.