Next.js course Β· Module 6: Forms and Server Actions

Error handling in Server Actions

7 min read
In this lesson8

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
Code for this lesson: App.tsx
1import React, { useState } from 'react';
2
3// Simulation of error handling in Server Actions (Next.js)
4// try/catch, error objects, useActionState, Zod validation
5
6type ActionState = {
7  status: 'idle' | 'success' | 'error';
8  message: string;
9  errors?: Record<string, string[]>;
10};
11
12// Simplified validation (simulation of Zod safeParse)
13function validateImplant(data: Record<string, string>) {
14  const errors: Record<string, string[]> = {};
15  if (!data.name || data.name.length < 2) {
16    errors.name = ['Name must have at least 2 characters'];
17  }
18  if (!data.powerLevel || isNaN(Number(data.powerLevel))) {
19    errors.powerLevel = ['A number is required'];
20  } else {
21    const pl = Number(data.powerLevel);
22    if (pl < 1 || pl > 100) errors.powerLevel = ['Power level: 1-100'];
23  }
24  if (!data.type) {
25    errors.type = ['Select an implant type'];
26  }
27  return { success: Object.keys(errors).length === 0, errors };
28}
29
30// Simulation of a Server Action with try/catch
31async function createImplantAction(data: Record<string, string>): Promise<ActionState> {
32  await new Promise(r => setTimeout(r, 800));
33
34  // 1. Validation with Zod (safeParse)
35  const validation = validateImplant(data);
36  if (!validation.success) {
37    return { status: 'error', message: 'Validation failed', errors: validation.errors };
38  }
39
40  // 2. Simulation of a server error
41  try {
42    if (data.name.toLowerCase() === 'error') {
43      throw new Error('Database connection failed');
44    }
45    if (data.name.toLowerCase() === 'auth') {
46      throw { type: 'AuthorizationError', message: 'No permission to create implants' };
47    }
48    return { status: 'success', message: `Implant "${data.name}" (power: ${data.powerLevel}) created!` };
49  } catch (error: any) {
50    // NEVER return the raw error to the client
51    if (error.type === 'AuthorizationError') {
52      return { status: 'error', message: error.message };
53    }
54    console.error('Server error:', error);
55    return { status: 'error', message: 'Server error. Try again later.' };
56  }
57}
58
59export default function ServerActionsErrorDemo() {
60  const [values, setValues] = useState({ name: '', powerLevel: '', type: '' });
61  const [state, setState] = useState<ActionState>({ status: 'idle', message: '' });
62  const [isPending, setIsPending] = useState(false);
63
64  const handleSubmit = async (e: React.FormEvent) => {
65    e.preventDefault();
66    setIsPending(true);
67    setState({ status: 'idle', message: '' });
68    const result = await createImplantAction(values);
69    setState(result);
70    setIsPending(false);
71  };
72
73  const inputStyle: React.CSSProperties = {
74    width: '100%', padding: '10px', background: '#0d1117',
75    border: '1px solid #333', borderRadius: 6, color: '#e0e0e0',
76    fontFamily: 'monospace', fontSize: 13, boxSizing: 'border-box'
77  };
78
79  const types = ['neural', 'cybernetic', 'bionic'];
80
81  const codeExample = `// app/actions.ts
82"use server";
83
84export async function createImplant(prevState, formData) {
85  const parsed = implantSchema.safeParse({...});
86  if (!parsed.success) {
87    return { status: 'error', errors: parsed.error.issues };
88  }
89  try {
90    await db.implants.create(parsed.data);
91    return { status: 'success', message: 'Created!' };
92  } catch (error) {
93    return { status: 'error', message: 'Server error' };
94  }
95}`;
96
97  return (
98    <div style={{ background: '#0f0f23', minHeight: '100vh', padding: 20, color: '#e0e0e0', fontFamily: 'monospace' }}>
99      <h1 style={{ color: '#64ffda' }}>Handling Errors in Server Actions</h1>
100      <p style={{ color: '#888', fontSize: 13, marginBottom: 12 }}>
101        Type "error" in the name = server error. "auth" = no permissions. Empty fields = Zod validation.
102      </p>
103
104      <pre style={{ background: '#0d1117', border: '1px solid #333', borderRadius: 8, padding: 12, fontSize: 11, color: '#ce93d8', marginBottom: 16, whiteSpace: 'pre-wrap' }}>
105{codeExample}
106      </pre>
107
108      <form onSubmit={handleSubmit} style={{ maxWidth: 400 }}>
109        <div style={{ marginBottom: 12 }}>
110          <label style={{ color: '#64ffda', fontSize: 12, display: 'block', marginBottom: 4 }}>Implant name</label>
111          <input
112            value={values.name}
113            onChange={e => setValues(p => ({ ...p, name: e.target.value }))}
114            placeholder="Neural Link"
115            style={{ ...inputStyle, borderColor: state.errors?.name ? '#f44336' : '#333' }}
116          />
117          {state.errors?.name && <div style={{ color: '#f44336', fontSize: 11, marginTop: 4 }}>{state.errors.name[0]}</div>}
118        </div>
119
120        <div style={{ marginBottom: 12 }}>
121          <label style={{ color: '#64ffda', fontSize: 12, display: 'block', marginBottom: 4 }}>Power level (1-100)</label>
122          <input
123            value={values.powerLevel}
124            onChange={e => setValues(p => ({ ...p, powerLevel: e.target.value }))}
125            placeholder="75"
126            style={{ ...inputStyle, borderColor: state.errors?.powerLevel ? '#f44336' : '#333' }}
127          />
128          {state.errors?.powerLevel && <div style={{ color: '#f44336', fontSize: 11, marginTop: 4 }}>{state.errors.powerLevel[0]}</div>}
129        </div>
130
131        <div style={{ marginBottom: 12 }}>
132          <label style={{ color: '#64ffda', fontSize: 12, display: 'block', marginBottom: 4 }}>Implant type</label>
133          <div style={{ display: 'flex', gap: 8 }}>
134            {types.map(t => (
135              <button key={t} type="button" onClick={() => setValues(p => ({ ...p, type: t }))} style={{
136                flex: 1, padding: '8px', borderRadius: 6, fontFamily: 'monospace', fontSize: 12, cursor: 'pointer',
137                background: values.type === t ? '#1565c0' : '#0d1117',
138                color: values.type === t ? '#fff' : '#aaa',
139                border: `1px solid ${values.type === t ? '#64ffda' : state.errors?.type ? '#f44336' : '#333'}`
140              }}>
141                {t}
142              </button>
143            ))}
144          </div>
145          {state.errors?.type && <div style={{ color: '#f44336', fontSize: 11, marginTop: 4 }}>{state.errors.type[0]}</div>}
146        </div>
147
148        <button type="submit" disabled={isPending} style={{
149          background: isPending ? '#555' : '#e91e63', color: '#fff', border: 'none', borderRadius: 6,
150          padding: '10px 20px', cursor: isPending ? 'wait' : 'pointer', fontFamily: 'monospace', width: '100%'
151        }}>
152          {isPending ? 'Validating on the server...' : 'createImplant() - Server Action'}
153        </button>
154      </form>
155
156      {state.status !== 'idle' && !state.errors && (
157        <div style={{
158          marginTop: 16, borderRadius: 8, padding: 12, maxWidth: 400,
159          background: state.status === 'success' ? '#1b5e20' : '#3e1010',
160          border: `1px solid ${state.status === 'success' ? '#4caf50' : '#f44336'}`
161        }}>
162          <div style={{ color: state.status === 'success' ? '#4caf50' : '#f44336' }}>
163            {state.message}
164          </div>
165        </div>
166      )}
167    </div>
168  );
169}

Spotted a mistake in this lesson?

Useful articles