We use cookies to enhance your experience on the site
CodeWorlds

Error handling in Server Actions

In Metropolis Quantum even the best systems can encounter errors — unexpected user input, database issues, or external API failures. Proper error handling in Server Actions is a key element of building reliable Next.js applications. In this lesson you'll learn patterns that will let you gracefully handle errors and inform users about them.

Try/catch in Server Actions

The basic error handling pattern in Server Actions is try/catch. Every server action should anticipate the possibility of an error occurring:

1// app/actions.ts
2'use server';
3
4export async function createImplant(formData: FormData) {
5  try {
6    const name = formData.get('name') as string;
7    const powerLevel = Number(formData.get('powerLevel'));
8
9    // Operation that may fail
10    const result = await db.implants.create({ name, powerLevel });
11
12    return { success: true, data: result };
13  } catch (error) {
14    // NEVER return the raw error object to the client!
15    console.error('Implant creation error:', error);
16    return {
17      success: false,
18      error: 'Failed to create the implant. Please try again.'
19    };
20  }
21}

Key rule: Server Actions should not throw exceptions to the client. Instead, return a structured object with success or error information.

Pattern for returning error objects

In professional applications, it's worth defining a consistent type for Server Actions responses:

1// lib/types.ts
2type ActionState = {
3  status: 'idle' | 'success' | 'error';
4  message: string;
5  errors?: Record<string, string[]>; // Per-field form errors
6};
7
8// app/actions.ts
9'use server';
10
11export async function updateProfile(
12  prevState: ActionState,
13  formData: FormData
14): Promise<ActionState> {
15  const name = formData.get('name') as string;
16  const email = formData.get('email') as string;
17
18  // Validation
19  const errors: Record<string, string[]> = {};
20
21  if (!name || name.length < 2) {
22    errors.name = ['First name must be at least 2 characters'];
23  }
24  if (!email || !email.includes('@')) {
25    errors.email = ['Invalid email address format'];
26  }
27
28  if (Object.keys(errors).length > 0) {
29    return {
30      status: 'error',
31      message: 'The form contains errors',
32      errors,
33    };
34  }
35
36  try {
37    await db.users.update({ name, email });
38    return { status: 'success', message: 'Profile updated!' };
39  } catch (error) {
40    return { status: 'error', message: 'Server error. Please try again.' };
41  }
42}

This pattern — an object with

status
,
message
, and an optional
errors
field — lets the client precisely understand what went wrong.

useActionState for displaying errors

The

useActionState
hook (formerly
useFormState
) is the official way to connect Server Actions with the user interface in React/Next.js:

1'use client';
2
3import { useActionState } from 'react';
4import { updateProfile } from './actions';
5
6const initialState = {
7  status: 'idle',
8  message: '',
9  errors: {},
10};
11
12export default function ProfileForm() {
13  const [state, formAction, isPending] = useActionState(
14    updateProfile,
15    initialState
16  );
17
18  return (
19    <form action={formAction}>
20      <div>
21        <label htmlFor="name">First name</label>
22        <input id="name" name="name" />
23        {state.errors?.name && (
24          <p className="text-red-500">{state.errors.name[0]}</p>
25        )}
26      </div>
27
28      <div>
29        <label htmlFor="email">Email</label>
30        <input id="email" name="email" type="email" />
31        {state.errors?.email && (
32          <p className="text-red-500">{state.errors.email[0]}</p>
33        )}
34      </div>
35
36      <button type="submit" disabled={isPending}>
37        {isPending ? 'Saving...' : 'Save profile'}
38      </button>
39
40      {state.status === 'success' && (
41        <p className="text-green-500">{state.message}</p>
42      )}
43      {state.status === 'error' && !state.errors && (
44        <p className="text-red-500">{state.message}</p>
45      )}
46    </form>
47  );
48}

useActionState
takes three arguments: the server action, the initial state, and returns an array: the current state, the formAction function (pass it to the form's
action
), and the
isPending
flag.

Zod validation in Server Actions

Combining Zod with Server Actions is a powerful pattern — the validation schema is shared between client and server:

1// lib/schemas.ts
2import { z } from 'zod';
3
4export const implantSchema = z.object({
5  name: z.string()
6    .min(2, 'Implant name must be at least 2 characters')
7    .max(50, 'Implant name can be up to 50 characters'),
8  powerLevel: z.coerce.number()
9    .min(1, 'Power level must be greater than 0')
10    .max(100, 'Power level cannot exceed 100'),
11  type: z.enum(['neural', 'cybernetic', 'bionic'], {
12    errorMap: () => ({ message: 'Select an implant type' }),
13  }),
14});
15
16// app/actions.ts
17'use server';
18
19import { implantSchema } from '@/lib/schemas';
20
21export async function createImplant(prevState: any, formData: FormData) {
22  // Validation with Zod
23  const parsed = implantSchema.safeParse({
24    name: formData.get('name'),
25    powerLevel: formData.get('powerLevel'),
26    type: formData.get('type'),
27  });
28
29  if (!parsed.success) {
30    // Convert Zod errors to field -> messages format
31    const fieldErrors: Record<string, string[]> = {};
32    for (const issue of parsed.error.issues) {
33      const field = issue.path[0] as string;
34      if (!fieldErrors[field]) fieldErrors[field] = [];
35      fieldErrors[field].push(issue.message);
36    }
37    return { status: 'error', message: 'Validation failed', errors: fieldErrors };
38  }
39
40  try {
41    // parsed.data is typed and validated!
42    await db.implants.create(parsed.data);
43    return { status: 'success', message: 'Implant created!' };
44  } catch (error) {
45    return { status: 'error', message: 'Database write error' };
46  }
47}

Notice

z.coerce.number()
— converts a string from FormData to a number before validation. This is key because
formData.get()
always returns a string.

Custom error types

In complex applications, it's worth defining custom error types to distinguish their source:

1// lib/errors.ts
2export class ValidationError extends Error {
3  constructor(
4    public fieldErrors: Record<string, string[]>,
5    message = 'Validation failed'
6  ) {
7    super(message);
8    this.name = 'ValidationError';
9  }
10}
11
12export class DatabaseError extends Error {
13  constructor(message = 'Database error') {
14    super(message);
15    this.name = 'DatabaseError';
16  }
17}
18
19export class AuthorizationError extends Error {
20  constructor(message = 'Insufficient permissions') {
21    super(message);
22    this.name = 'AuthorizationError';
23  }
24}
25
26// app/actions.ts
27'use server';
28
29import { ValidationError, DatabaseError, AuthorizationError } from '@/lib/errors';
30
31export async function deleteImplant(prevState: any, formData: FormData) {
32  try {
33    const id = formData.get('id') as string;
34
35    if (!id) {
36      throw new ValidationError({ id: ['Implant ID is required'] });
37    }
38
39    const user = await getCurrentUser();
40    if (!user?.isAdmin) {
41      throw new AuthorizationError('Only administrators can delete implants');
42    }
43
44    await db.implants.delete(id);
45    return { status: 'success', message: 'Implant deleted' };
46
47  } catch (error) {
48    if (error instanceof ValidationError) {
49      return { status: 'error', message: error.message, errors: error.fieldErrors };
50    }
51    if (error instanceof AuthorizationError) {
52      return { status: 'error', message: error.message };
53    }
54    if (error instanceof DatabaseError) {
55      return { status: 'error', message: 'System error. Please try again later.' };
56    }
57    // Unknown error — don't expose details to the user
58    console.error('Unexpected error:', error);
59    return { status: 'error', message: 'An unexpected error occurred' };
60  }
61}

User-friendly messages

Error messages should be understandable and helpful — the user must know what to do:

1// Bad messages:
2"Error: ECONNREFUSED 127.0.0.1:5432"  // Technical, unintelligible
3"Validation failed"                     // Too generic
4"null"                                  // Nonsensical
5
6// Good messages:
7"Failed to save data. Check your connection and try again."
8"Implant name must be at least 2 characters."
9"Session expired. Log in again to continue."

Fallback behaviors

When a Server Action fails, the application should have a contingency plan:

1'use client';
2
3import { useActionState } from 'react';
4import { saveData } from './actions';
5
6export default function FormWithFallback() {
7  const [state, formAction, isPending] = useActionState(saveData, {
8    status: 'idle',
9    message: '',
10  });
11
12  // Fallback: retry after error
13  const handleRetry = () => {
14    // Reset state and let the user try again
15    window.location.reload();
16  };
17
18  if (state.status === 'error' && state.message.includes('server')) {
19    return (
20      <div>
21        <p>A server problem occurred.</p>
22        <button onClick={handleRetry}>Try again</button>
23        <p>If the problem persists, contact the administrator.</p>
24      </div>
25    );
26  }
27
28  return (
29    <form action={formAction}>
30      {/* ... form fields ... */}
31      <button type="submit" disabled={isPending}>
32        {isPending ? 'Saving...' : 'Save'}
33      </button>
34    </form>
35  );
36}

Summary

Error handling in Server Actions is a critical element of reliable Next.js applications:

  • try/catch in every Server Action — never let raw errors reach the client
  • Structured error objects
    { status, message, errors }
    instead of throwing exceptions
  • useActionState — official hook for connecting actions with UI and displaying per-field errors
  • Zod in Server Actions
    safeParse()
    +
    z.coerce
    for validating data from FormData
  • Custom error types — distinguishing error sources (validation, authorization, database)
  • User-friendly messages — understandable, specific, with instructions on what to do next
Go to CodeWorlds