DEV Community

Cover image for Notes from a 2 AM On-Call: Next.js Server Actions Silently Swallowing Zod Errors in Production
Elena Rostova
Elena Rostova

Posted on

Notes from a 2 AM On-Call: Next.js Server Actions Silently Swallowing Zod Errors in Production

Notes from a 2 AM On-Call: Next.js Server Actions Silently Swallowing Zod Errors in Production

The Failure Symptom

[02:17:43] POST /api/actions/submit 200 OK 127ms
[02:17:43] Client: formState.errors = {}
[02:17:43] DB: 0 rows inserted
[02:17:43] Sentry: 0 exceptions
Enter fullscreen mode Exit fullscreen mode

Our user-facing form returned success. The database wrote nothing. No errors surfaced client-side. SLA breach alerts fired 90 seconds later when the downstream ETL pipeline starved.

The actual payload that hit our Server Action:

{
  "userId": "usr_19xKlM",
  "amount": "not-a-number",
  "currency": null
}
Enter fullscreen mode Exit fullscreen mode

Zod should have rejected this immediately. It didn't.

The Red Herring

Every Next.js 15 tutorial on Server Actions shows this pattern:

// actions/submit.ts
'use server'

import { z } from 'zod'

const schema = z.object({
  userId: z.string(),
  amount: z.number().positive(),
  currency: z.enum(['USD', 'EUR', 'GBP'])
})

export async function submitTransaction(formData: FormData) {
  const parsed = schema.parse({
    userId: formData.get('userId'),
    amount: Number(formData.get('amount')),
    currency: formData.get('currency')
  })

  // DB write...
  return { success: true }
}
Enter fullscreen mode Exit fullscreen mode

We followed this verbatim. Under load testing with 50 concurrent users, it worked flawlessly. In production with DavidHDev/react-bits interactive form components handling real edge cases (network drops, partial form state, browser autofill mangling types), it silently failed.

The smoking gun: Next.js Server Actions wrap every invocation in an implicit try-catch that serializes errors through React's internal streaming protocol. When schema.parse() throws a ZodError, Next.js catches it, attempts to serialize it, discovers it's not a plain object, and returns an empty response body with HTTP 200.

The client's useFormState hook sees 200 OK with no payload and interprets it as success with no validation errors.

The Root Cause & Runnable Fix

Zod errors don't survive Next.js serialization boundaries unless explicitly flattened. Here's the corrected action:

// actions/submit.ts
'use server'

import { z } from 'zod'

const schema = z.object({
  userId: z.string().min(1),
  amount: z.number().positive(),
  currency: z.enum(['USD', 'EUR', 'GBP'])
})

export type ActionResult = 
  | { success: true; transactionId: string }
  | { success: false; errors: Record<string, string[]> }

export async function submitTransaction(
  prevState: ActionResult | null,
  formData: FormData
): Promise<ActionResult> {
  const rawData = {
    userId: formData.get('userId') as string,
    amount: Number(formData.get('amount')),
    currency: formData.get('currency') as string
  }

  const validation = schema.safeParse(rawData)

  if (!validation.success) {
    return {
      success: false,
      errors: validation.error.flatten().fieldErrors
    }
  }

  const { userId, amount, currency } = validation.data
  const txId = await db.transactions.insert({ userId, amount, currency })

  return { success: true, transactionId: txId }
}
Enter fullscreen mode Exit fullscreen mode

Client consumption with type safety:

// components/TransactionForm.tsx
'use client'

import { useFormState } from 'react-dom'
import { submitTransaction } from '@/actions/submit'

export function TransactionForm() {
  const [state, formAction] = useFormState(submitTransaction, null)

  return (
    <form action={formAction}>
      <input name="userId" />
      {state?.success === false && state.errors.userId && (
        <span className="error">{state.errors.userId[0]}</span>
      )}

      <input name="amount" type="number" />
      {state?.success === false && state.errors.amount && (
        <span className="error">{state.errors.amount[0]}</span>
      )}

      <select name="currency">
        <option value="USD">USD</option>
        <option value="EUR">EUR</option>
        <option value="GBP">GBP</option>
      </select>

      <button type="submit">Submit</button>

      {state?.success === true && (
        <p>Transaction {state.transactionId} created</p>
      )}
    </form>
  )
}
Enter fullscreen mode Exit fullscreen mode

The three critical changes:

  1. safeParse() instead of parse(): Returns a discriminated union instead of throwing
  2. flatten().fieldErrors: Converts nested Zod error structure into serialization-safe plain object
  3. Discriminated union return type: Forces exhaustive client-side handling

Production Reality Check

When integrating DavidHDev/react-bits interactive components (animated inputs, multi-step forms, conditional fields), traditional Server Action error handling breaks down. The library's client-side state management expects explicit error shapes, not thrown exceptions that vanish into Next.js's serialization black hole.

After deploying this pattern across 14 Server Actions:

  • Zero silent failures in 72 hours
  • Client-side error display latency dropped from 2.3s (after retry logic fired) to 180ms
  • Sentry noise reduced by 89% (no more unhandledRejection spam from React internals)

Invariant Rule

Never throw in Server Actions. Return discriminated unions.

If a Server Action can fail, its return type must be { success: true, data: T } | { success: false, error: E }. Exceptions are for bugs (null pointer, out of memory), not for validation or business logic failures. Next.js serialization boundaries will eat your exceptions and leave your clients guessing.

The moment you write throw new Error() in a Server Action, you've created a production timebomb that only triggers under concurrent load with real user input entropy.

Top comments (0)