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 = {}
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
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
}
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 }
}
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 }
}
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>
)
}
The three critical changes:
-
safeParse()instead ofparse(): Returns a discriminated union instead of throwing -
flatten().fieldErrors: Converts nested Zod error structure into serialization-safe plain object - 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
unhandledRejectionspam 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.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.