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.
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.
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.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.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
— converts a string from FormData to a number before validation. This is key because z.coerce.number()
formData.get() always returns a string.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}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."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}Error handling in Server Actions is a critical element of reliable Next.js applications:
{ status, message, errors } instead of throwing exceptionssafeParse() + z.coerce for validating data from FormData