Next.js Server Actions Error Handling: Production Guide

Server Action errors are sanitized in production and redirect() gets swallowed by try/catch. Here is how to return typed errors, show them in forms, and log the real cause.

The reliable way to handle errors in Next.js Server Actions is to return expected failures as values ({ ok: false, error }), re-throw everything unexpected so error boundaries and logging see it, and never wrap redirect() in a try/catch that swallows it.

Server Actions look like plain functions, which tempts developers to treat errors like plain functions too. In production that breaks in three ways, covered below.

Why Server Action Errors Are Confusing in Production

In development you see the real stack trace. In production, Next.js replaces thrown error messages from the server with a generic one so internals do not leak to the browser:

An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive information.

The real message exists only in your server logs, with a digest hash to match them. If you are not collecting server logs, you have no idea what actually failed. See Vercel Log Drains for keeping those logs past the lifetime of the function.

Pattern: Return Expected Errors, Throw Unexpected Ones

Split failures into two buckets:

Bucket Example What to do
Expected Validation failed, email taken, not authorized Return a typed result
Unexpected DB down, null deref, bad deploy Throw, log, show error boundary
// app/actions/signup.ts
'use server'
import { z } from 'zod'

const schema = z.object({ email: z.string().email(), name: z.string().min(2) })

export type ActionResult =
  | { ok: true }
  | { ok: false; fieldErrors?: Record<string, string[]>; message?: string }

export async function signup(_prev: ActionResult | null, formData: FormData): Promise<ActionResult> {
  const parsed = schema.safeParse(Object.fromEntries(formData))
  if (!parsed.success) {
    return { ok: false, fieldErrors: parsed.error.flatten().fieldErrors }
  }
  try {
    await createUser(parsed.data)
  } catch (e: any) {
    if (e?.code === 'P2002') return { ok: false, message: 'Email already registered' }
    throw e // unexpected: let it reach logging and the error boundary
  }
  return { ok: true }
}

Validate on the server even if the client already validated. Client checks are a UX nicety, not a security boundary. The Zod API validation guide goes deeper, and the Prisma branch above is explained in Prisma errors in production.

Show the Error in the Form

Use useActionState (React 19) so the returned value drives the UI without extra state:

'use client'
import { useActionState } from 'react'
import { signup } from '@/app/actions/signup'

export function SignupForm() {
  const [state, action, pending] = useActionState(signup, null)
  return (
    <form action={action}>
      <input name="email" />
      {state?.ok === false && <p role="alert">{state.message}</p>}
      <button disabled={pending}>Sign up</button>
    </form>
  )
}

The redirect() Trap

redirect() works by throwing a special error. If you call it inside try and catch everything, the redirect never happens and the user sees nothing.

// Wrong: the redirect is swallowed
try {
  await save()
  redirect('/done')
} catch (e) {
  return { ok: false }
}

// Right: redirect after the try/catch
try {
  await save()
} catch (e) {
  return { ok: false }
}
redirect('/done')

Add an Error Boundary for the Unexpected Path

Thrown errors bubble to the nearest error.tsx. Add one per route segment that contains forms, and keep a root global-error.tsx. Patterns are in React Error Boundaries in Production.

Let Relia Explain the Ones You Do Not See

A thrown Server Action error reaches users as an opaque message and reaches you as one line in a log. Relia connects the error with the user's session and the requests around it and writes the incident up in plain words: what happened, why it thinks so (with a confidence level), and one thing to do next. On request it writes a fix plan with a "Copy for your AI assistant" button; Relia doesn't change code itself, and after you ship it checks the fix against live traffic. You stop guessing which form is failing.

FAQ

Can I use try/catch in a Server Action?

Yes. Catch only what you can handle, return a typed error for it, and re-throw the rest. Never catch redirect() or notFound().

Why is my Server Action error message hidden in production?

Next.js strips thrown messages from the client to avoid leaking secrets. Return a safe message as a value, and read the real one in server logs using the digest.

Should Server Actions return errors or throw them?

Return expected, user-fixable errors. Throw unexpected ones so they are logged and trigger the error boundary.

All guides