EdgeCases Logo
Apr 2026
Next.js
Deep
9 min read

Next.js Server Actions Error Handling Patterns

useActionState, error boundaries, and validation failures all interact in Server Actions. Learn patterns for robust error handling without confusing UX.

Next.js
Server Actions
Error Handling
Validation
useActionState

Next.js Server Actions (experimental in 14, stable in 15+) brought progressive enhancement to forms—but error handling is surprisingly complex. With useActionState, error boundaries, and validation failures all interacting, it's easy to accidentally swallow errors or show confusing UX.

The Problem: Multiple Error Pathways

Server Actions have three distinct error pathways, each requiring different handling:

  • Validation errors — Return error messages without throwing
  • Runtime errors — Throw and let error boundaries catch
  • Network errors — Client-side fetch failures

Pattern 1: Validation with useFormStatus

For form validation, don't throw. Return error objects instead:

'use server';

import { z } from 'zod';

const schema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});

export async function login(prevState: any, formData: FormData) {
  const data = {
    email: formData.get('email'),
    password: formData.get('password'),
  };

  const result = schema.safeParse(data);

  if (!result.success) {
    // ❌ Don't throw validation errors
    return {
      errors: result.error.flatten().fieldErrors,
    };
  }

  // ... authentication logic
  return { success: true };
}

Pattern 2: useActionState for Error State

Use useActionState to handle both validation and runtime errors:

'use client';

import { useActionState } from 'react';
import { login } from './actions';

export function LoginForm() {
  const [state, formAction, isPending] = useActionState(
    login,
    { errors: {}, success: false }
  );

  return (
    <form action={formAction}>
      {state.errors.email && (
        <p className="error">{state.errors.email[0]}</p>
      )}
      <input name="email" type="email" />

      <button disabled={isPending}>
        {isPending ? 'Submitting...' : 'Login'}
      </button>

      {state.success && (
        <p className="success">Logged in!</p>
      )}
    </form>
  );
}

Pattern 3: Error Boundaries for Runtime Errors

When a Server Action throws (not validation errors), it's caught by the nearest error boundary. But here's the edge case: errors thrown in Server Actions don't automatically re-render the form with the error message.

'use server';

export async function deleteUser(id: string) {
  // ❌ Throwing here doesn't show error in form
  if (!hasPermission()) {
    throw new Error('Unauthorized');
  }

  await db.users.delete(id);
}

Use redirect() or return error objects instead:

'use server';

import { redirect } from 'next/navigation';

export async function deleteUser(id: string) {
  if (!hasPermission()) {
    // ✅ Redirect to error page
    redirect('/login?error=unauthorized');
  }

  await db.users.delete(id);
  redirect('/users');
}

Or catch errors at the client:

'use client';

import { useActionState } from 'react';

export function DeleteUserForm({ id }: { id: string }) {
  const [state, formAction] = useActionState(
    async () => {
      try {
        await deleteUser(id);
        return { success: true };
      } catch (error) {
        return { error: error instanceof Error ? error.message : 'Unknown error' };
      }
    },
    { success: false, error: null }
  );

  return (
    <form action={formAction}>
      {state.error && (
        <p className="error">{state.error}</p>
      )}
      <button type="submit">Delete</button>
    </form>
  );
}

Edge Case 1: Mutation Race Conditions

Server Actions run sequentially per user, but multiple forms can submit simultaneously. This creates race conditions:

// User clicks "Save" twice quickly:
// Request 1: Load user, update name to "Alice"
// Request 2: Load user, update name to "Bob"
// Result: "Bob" wins, "Alice" lost forever

Solutions:

'use server';

// ✅ Use optimistic concurrency control
export async function updateUser(id: string, data: UserData) {
  const current = await db.users.findUnique({ where: { id } });

  if (current.version !== data.version) {
    throw new Error('User was modified by another user');
  }

  const updated = await db.users.update({
    where: { id },
    data: { ...data, version: current.version + 1 },
  });

  return updated;
}

// ✅ Or use database transactions
export async function updateUserWithTransaction(id: string, data: UserData) {
  return await db.$transaction(async (tx) => {
    const current = await tx.users.findUnique({ where: { id } });
    // ... validation
    return await tx.users.update({ where: { id }, data });
  });
}

Edge Case 2: Partial Failures in Batch Actions

When a Server Action performs multiple operations, partial failures create inconsistent state:

'use server';

// ❌ Bad: Inconsistent state on failure
export async function batchDelete(ids: string[]) {
  for (const id of ids) {
    await db.users.delete({ where: { id } });
    // If this throws, previous deletes are already committed
  }
}

Use transactions or idempotent operations:

'use server';

// ✅ Good: Atomic transaction
export async function batchDelete(ids: string[]) {
  await db.$transaction(
    ids.map(id =>
      db.users.delete({ where: { id } })
    )
  );
}

// ✅ Good: Idempotent with retry
export async function softDelete(id: string) {
  await db.users.update({
    where: { id },
    data: { deletedAt: new Date() }
  });
}

Edge Case 3: Error Boundary Reset on Action

When a Server Action throws and the error boundary catches it, the form resets. This is confusing UX—users lose their input.

'use client';

import { ErrorBoundary } from 'react-error-boundary';

export function LoginForm() {
  return (
    <ErrorBoundary
      fallback={<ErrorMessage />}
      resetKeys={['login-error']}
    >
      <form action={login}>
        {/* Form inputs */}
      </form>
    </ErrorBoundary>
  );
}

Better approach: handle errors in the action, don't let them propagate to the boundary:

'use server';

export async function login(prevState: any, formData: FormData) {
  try {
    // ... authentication
    return { success: true };
  } catch (error) {
    // ✅ Return error instead of throwing
    return {
      error: error instanceof Error ? error.message : 'Login failed',
      data: Object.fromEntries(formData), // Preserve form data
    };
  }
}

Progressive Enhancement: Graceful Degradation

Server Actions work without JavaScript, but you need to handle both cases:

'use client';

import { useFormStatus } from 'react-dom';

export function SubmitButton() {
  const { pending } = useFormStatus();

  return (
    <button disabled={pending}>
      {pending ? 'Submitting...' : 'Submit'}
    </button>
  );
}

export function LoginForm() {
  return (
    <form action={login}>
      <input name="email" required />
      <input name="password" required />
      <SubmitButton />
    </form>
  );
}

With JavaScript disabled, the button shows "Submit" and the form submits normally. With JavaScript, useFormStatus provides pending state.

Key Takeaways

  • Validation errors: Return objects, don't throw
  • Runtime errors: Use try/catch in actions or redirect
  • Race conditions: Implement optimistic concurrency or transactions
  • Partial failures: Use database transactions for atomicity
  • UX preservation: Return form data on error to prevent input loss
  • Progressive enhancement: Ensure forms work without JavaScript

Advertisement

Related Insights

Explore related edge cases and patterns

TypeScript
Expert
TypeScript Mapped Type Modifiers: When Inference Breaks
7 min
Next.js
Deep
Next.js 'use cache': Explicit Caching with Automatic Keys
9 min
Next.js
Deep
Next.js Parallel and Intercepting Routes
8 min

Advertisement