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 (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 foreverSolutions:
'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
Explore these curated resources to deepen your understanding
Official Documentation
Tools & Utilities
Related Insights
Explore related edge cases and patterns
Advertisement