Error Handling and Status in Server Actions
Server Actions are async functions that can fail: database errors, network timeouts, validation failures, permission denials. You need a strategy to catch these errors, log them securely, and display user-friendly messages without exposing sensitive details. Next.js serializes errors thrown in Server Actions, sending them to the client where you can catch and handle them. The key is distinguishing between errors you should show to users (e.g., "Email already exists") and errors you should log privately (database connection failures).
Error handling in Server Actions is straightforward: throw errors in the action, catch them in the component, and display messages. For more control, return error states as part of the action's response instead of throwing. This pairs well with useActionState, which tracks errors without requiring try/catch.
Throwing Errors in Server Actions
The simplest approach: throw an error when something goes wrong. Next.js serializes it and sends it to the client.
// app/actions.ts
'use server';
import { db } from '@/lib/db';
import { z } from 'zod';
const emailSchema = z.string().email();
export async function registerUser(formData: FormData) {
const email = formData.get('email') as string;
// Validate
const result = emailSchema.safeParse(email);
if (!result.success) {
throw new Error('Invalid email address');
}
// Check if user exists
const existing = await db.users.findByEmail(email);
if (existing) {
throw new Error('Email already registered');
}
// Create user
try {
const user = await db.users.create({ email });
return { success: true, userId: user.id };
} catch (dbError) {
// Log the real error privately
console.error('[registerUser] Database error:', dbError);
// Return a generic message to the user
throw new Error('Failed to create account. Please try again.');
}
}
In the component, catch the error with try/catch:
// app/components/RegisterForm.tsx
'use client';
import { useState } from 'react';
import { registerUser } from '@/app/actions';
export function RegisterForm() {
const [error, setError] = useState<string | null>(null);
const [isPending, setIsPending] = useState(false);
async function handleSubmit(formData: FormData) {
setError(null);
setIsPending(true);
try {
const result = await registerUser(formData);
console.log('Registered:', result.userId);
// Redirect or update UI
} catch (err) {
// Error is serialized from the server
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setIsPending(false);
}
}
return (
<form action={handleSubmit}>
<input type="email" name="email" placeholder="Email" required />
<button type="submit" disabled={isPending}>
{isPending ? 'Registering...' : 'Register'}
</button>
{error && <p style={{ color: 'red' }}>{error}</p>}
</form>
);
}
Returning Error States with useActionState
For cleaner code and better UX, return error states instead of throwing. This works well with useActionState:
// app/actions.ts
'use server';
import { db } from '@/lib/db';
export async function registerUser(prevState: any, formData: FormData) {
const email = formData.get('email') as string;
// Validation
if (!email || !email.includes('@')) {
return { success: false, error: 'Invalid email address', userId: null };
}
// Check if exists
const existing = await db.users.findByEmail(email);
if (existing) {
return { success: false, error: 'Email already registered', userId: null };
}
// Create
try {
const user = await db.users.create({ email });
return { success: true, error: null, userId: user.id };
} catch (dbError) {
console.error('[registerUser] Database error:', dbError);
return { success: false, error: 'Failed to create account', userId: null };
}
}
// app/components/RegisterForm.tsx
'use client';
import { useActionState } from 'react';
import { registerUser } from '@/app/actions';
export function RegisterForm() {
const [state, formAction, isPending] = useActionState(registerUser, {
success: false,
error: null,
userId: null,
});
return (
<form action={formAction}>
<input type="email" name="email" placeholder="Email" required disabled={isPending} />
<button type="submit" disabled={isPending}>
{isPending ? 'Registering...' : 'Register'}
</button>
{state?.error && <p style={{ color: 'red' }}>{state.error}</p>}
{state?.success && <p style={{ color: 'green' }}>Account created!</p>}
</form>
);
}
No try/catch needed. The action returns error state, useActionState exposes it, and you display it. This is cleaner because error handling is built-in to the hook.
Distinguishing User-Facing and Internal Errors
Always separate errors you can show to users from errors you must log privately:
User-facing errors (safe to display):
- Validation errors: "Email is invalid"
- Business logic errors: "Email already registered"
- Rate limiting: "Too many attempts. Try again in 5 minutes."
Internal errors (log privately, show generic message):
- Database connection failures
- External API timeouts
- Third-party service errors
- Authentication/authorization errors (sometimes)
// app/actions.ts
'use server';
import { db } from '@/lib/db';
export async function sendEmail(email: string) {
try {
const result = await externalEmailService.send(email);
return { success: true };
} catch (error) {
// Log internally
console.error('[sendEmail] Service error:', {
timestamp: new Date().toISOString(),
email,
error: error instanceof Error ? error.message : String(error),
});
// Return generic message to user
throw new Error('Failed to send email. Our team has been notified.');
}
}
async function deleteAccount(userId: string) {
try {
await db.users.delete(userId);
return { success: true };
} catch (error) {
// Log with context
const logEntry = {
action: 'deleteAccount',
userId,
error: error instanceof Error ? error.message : String(error),
timestamp: new Date().toISOString(),
};
console.error('[deleteAccount]', logEntry);
// Check error type
if (error instanceof Error && error.message.includes('FOREIGN_KEY')) {
// User-friendly message for known errors
return { success: false, error: 'Cannot delete account with active subscriptions' };
}
// Generic fallback
return { success: false, error: 'Failed to delete account' };
}
}
Custom Error Classes
Create custom error classes for specific failure modes:
// lib/errors.ts
export class ValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'ValidationError';
}
}
export class NotFoundError extends Error {
constructor(resource: string) {
super(`${resource} not found`);
this.name = 'NotFoundError';
}
}
export class ConflictError extends Error {
constructor(message: string) {
super(message);
this.name = 'ConflictError';
}
}
export class InternalError extends Error {
constructor(message: string) {
super(message);
this.name = 'InternalError';
}
}
// app/actions.ts
'use server';
import { ValidationError, ConflictError, NotFoundError } from '@/lib/errors';
import { db } from '@/lib/db';
export async function updateTask(id: string, title: string) {
if (!title.trim()) {
throw new ValidationError('Title is required');
}
const task = await db.tasks.findById(id);
if (!task) {
throw new NotFoundError('Task');
}
// Check for duplicate
const existing = await db.tasks.findByTitle(title);
if (existing && existing.id !== id) {
throw new ConflictError('A task with this title already exists');
}
await db.tasks.update(id, { title });
return { success: true };
}
// app/components/EditTaskForm.tsx
'use client';
import { ValidationError, ConflictError, NotFoundError } from '@/lib/errors';
import { updateTask } from '@/app/actions';
export function EditTaskForm({ taskId, initialTitle }: { taskId: string; initialTitle: string }) {
const [error, setError] = useState<string | null>(null);
async function handleSubmit(formData: FormData) {
setError(null);
try {
const title = formData.get('title') as string;
await updateTask(taskId, title);
} catch (err) {
if (err instanceof ValidationError) {
setError(`Validation: ${err.message}`);
} else if (err instanceof ConflictError) {
setError(`Conflict: ${err.message}`);
} else if (err instanceof NotFoundError) {
setError(`Not found: ${err.message}`);
} else {
setError('An unexpected error occurred');
}
}
}
return (
<form action={handleSubmit}>
<input type="text" name="title" defaultValue={initialTitle} required />
<button type="submit">Update</button>
{error && <p style={{ color: 'red' }}>{error}</p>}
</form>
);
}
Status Codes in API-Like Actions
If a Server Action behaves like an API endpoint, return status information:
// app/actions.ts
'use server';
export async function updateProfile(formData: FormData) {
const email = formData.get('email') as string;
// Validation
if (!email) {
return { status: 400, error: 'Email is required', data: null };
}
// Check permissions
const user = await getCurrentUser();
if (!user) {
return { status: 401, error: 'Unauthorized', data: null };
}
// Process
try {
const updated = await db.users.update(user.id, { email });
return { status: 200, error: null, data: updated };
} catch (error) {
console.error('[updateProfile]', error);
return { status: 500, error: 'Failed to update profile', data: null };
}
}
// app/components/ProfileForm.tsx
async function handleSubmit(formData: FormData) {
const result = await updateProfile(formData);
if (result.status === 200) {
setSuccess('Profile updated');
} else if (result.status === 401) {
router.push('/login');
} else if (result.status === 400) {
setError(result.error);
} else {
setError('Server error. Please try again.');
}
}
Key Takeaways
- Throw user-facing errors from Server Actions; they're serialized and accessible in the client with try/catch.
- Return error states (using
useActionState) for cleaner code without try/catch. - Log internal errors (database failures, third-party API errors) privately; show generic messages to users.
- Use custom error classes to categorize different failure modes and handle them appropriately.
- Return status codes in Server Actions that behave like API endpoints for more control.
Frequently Asked Questions
Can I send different error messages to different users?
Yes. Check the user's role or permissions in the Server Action, and return role-specific error messages. For example, an admin might see "Database timeout"; a regular user sees "Server error, please try again."
How do I log errors securely?
Use a server-side logging service (e.g., Sentry, LogRocket, Datadog) that captures errors with context (user id, action, timestamp) without exposing sensitive data to the client. Never log PII (passwords, tokens) in any log.
What if the user's network fails during the action?
The client's fetch call times out (default 30 seconds), and the catch block triggers. You can show a "Network error" message and retry automatically or prompt the user to retry manually.
Should I catch all errors or let some propagate?
Catch and handle errors you can recover from or display to the user. Let unexpected errors propagate so your global error boundary or error logging catches them. This way, you handle expected cases gracefully and still catch unexpected ones.
How do I test error scenarios?
Mock the database or external service to throw errors: jest.mock('@/lib/db', () => ({ users: { create: jest.fn(() => Promise.reject(new Error('DB Error'))) } })). Then test that your action throws or returns the expected error state.