Published on

Type-Safe Server Actions in Next.js with Zod: A Deep Technical Analysis

Architecture & Execution Model

Server Actions in Next.js 14+ are compiled into internal endpoints and executed via React’s Server Components architecture. Unlike traditional API routes, they leverage React Flight protocol for serialization, enabling direct invocation from client components without explicit HTTP calls.

Key Technical Details:

  • Actions are bundled as separate chunks (/_next/static/chunks/)
  • FormData is automatically parsed and transmitted via React Flight
  • Execution context switches seamlessly between client and server boundaries
  • Built-in CSRF protection via cryptographic tokens

Implementation: Message Sending System

Let’s examine a production-ready message sending system with comprehensive error handling and type safety.

Schema Definition with Advanced Validation

import { z } from 'zod';

const messageSchema = z.object({
  recipientId: z.string().uuid('Invalid recipient ID format'),
  subject: z.string()
    .min(1, 'Subject is required')
    .max(200, 'Subject must be under 200 characters')
    .refine(s => s.trim().length > 0, 'Subject cannot be empty'),
  content: z.string()
    .min(10, 'Message must be at least 10 characters')
    .max(5000, 'Message exceeds maximum length')
    .transform(s => s.trim()),
  priority: z.enum(['low', 'normal', 'high']).default('normal'),
  attachments: z.array(z.object({
    filename: z.string(),
    size: z.number().max(10 * 1024 * 1024), // 10MB limit
    type: z.string().regex(/^[a-zA-Z]+\/[a-zA-Z0-9\-\+\.]+$/)
  })).max(5, 'Maximum 5 attachments allowed').optional()
});

type MessageInput = z.input<typeof messageSchema>;
type MessageOutput = z.output<typeof messageSchema>;

Server Action with Comprehensive Error Handling

'use server';

import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';

type ActionResult<T = never> = 
  | { success: true; data: T }
  | { success: false; error: string; fieldErrors?: Record<string, string[]> };

export async function sendMessage(
  prevState: ActionResult | null,
  formData: FormData
): Promise<ActionResult<{ messageId: string }>> {
  
  // Critical: Always validate session/auth first
  const session = await getServerSession();
  if (!session?.user?.id) {
    return { success: false, error: 'Authentication required' };
  }

  // Parse FormData with proper error handling
  const rawData = {
    recipientId: formData.get('recipientId'),
    subject: formData.get('subject'),
    content: formData.get('content'),
    priority: formData.get('priority') || 'normal',
    // Handle file attachments properly
    attachments: formData.getAll('attachments').map(file => {
      if (!(file instanceof File)) return null;
      return {
        filename: file.name,
        size: file.size,
        type: file.type
      };
    }).filter(Boolean)
  };

  // Schema validation with detailed error mapping
  const validation = messageSchema.safeParse(rawData);
  
  if (!validation.success) {
    const fieldErrors = validation.error.flatten().fieldErrors;
    return {
      success: false,
      error: 'Validation failed',
      fieldErrors
    };
  }

  const validatedData = validation.data;

  try {
    // Database transaction for consistency
    const result = await db.transaction(async (tx) => {
      // Verify recipient exists and user has permission
      const recipient = await tx.user.findUnique({
        where: { id: validatedData.recipientId },
        select: { id: true, blockedUsers: true }
      });

      if (!recipient) {
        throw new Error('Recipient not found');
      }

      if (recipient.blockedUsers.includes(session.user.id)) {
        throw new Error('Cannot send message to this user');
      }

      // Create message with proper relations
      const message = await tx.message.create({
        data: {
          senderId: session.user.id,
          recipientId: validatedData.recipientId,
          subject: validatedData.subject,
          content: validatedData.content,
          priority: validatedData.priority,
          attachments: validatedData.attachments ? {
            create: validatedData.attachments.map(att => ({
              filename: att.filename,
              size: att.size,
              mimeType: att.type
            }))
          } : undefined
        },
        select: { id: true }
      });

      // Update unread count atomically
      await tx.user.update({
        where: { id: validatedData.recipientId },
        data: { unreadMessages: { increment: 1 } }
      });

      return message;
    });

    // Revalidate affected routes
    revalidatePath('/messages');
    revalidatePath(`/messages/${validatedData.recipientId}`);

    // Optional: Trigger real-time notifications
    await notificationService.notify(validatedData.recipientId, {
      type: 'new_message',
      senderId: session.user.id,
      messageId: result.id
    });

    return {
      success: true,
      data: { messageId: result.id }
    };

  } catch (error) {
    console.error('Message sending failed:', error);
    
    // Return user-friendly error without exposing internals
    if (error instanceof Error) {
      if (error.message.includes('Recipient not found')) {
        return { success: false, error: 'Invalid recipient' };
      }
      if (error.message.includes('Cannot send message')) {
        return { success: false, error: 'Unable to send message to this user' };
      }
    }
    
    return { success: false, error: 'Failed to send message' };
  }
}

Client Component with Advanced State Management

'use client';

import { useFormState, useFormStatus } from 'react-dom';
import { useOptimistic, useTransition } from 'react';

interface Message {
  id: string;
  subject: string;
  content: string;
  createdAt: Date;
}

export function MessageForm({ recipientId }: { recipientId: string }) {
  const [state, formAction] = useFormState(sendMessage, null);
  const [isPending, startTransition] = useTransition();
  
  // Optimistic updates for better UX
  const [optimisticMessages, addOptimisticMessage] = useOptimistic<Message[]>(
    [],
    (state, newMessage: Message) => [...state, newMessage]
  );

  const handleSubmit = (formData: FormData) => {
    // Add optimistic message immediately
    const optimisticMessage = {
      id: crypto.randomUUID(),
      subject: formData.get('subject') as string,
      content: formData.get('content') as string,
      createdAt: new Date()
    };
    
    addOptimisticMessage(optimisticMessage);
    
    startTransition(() => {
      formAction(formData);
    });
  };

  return (
    <form action={handleSubmit} className="space-y-4">
      <input type="hidden" name="recipientId" value={recipientId} />
      
      <div>
        <label htmlFor="subject" className="block text-sm font-medium">
          Subject
        </label>
        <input
          id="subject"
          name="subject"
          type="text"
          required
          className="mt-1 block w-full rounded-md border-gray-300"
          aria-describedby={state?.fieldErrors?.subject ? "subject-error" : undefined}
        />
        {state?.fieldErrors?.subject && (
          <p id="subject-error" className="mt-1 text-sm text-red-600">
            {state.fieldErrors.subject[0]}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="content" className="block text-sm font-medium">
          Message
        </label>
        <textarea
          id="content"
          name="content"
          rows={6}
          required
          className="mt-1 block w-full rounded-md border-gray-300"
          aria-describedby={state?.fieldErrors?.content ? "content-error" : undefined}
        />
        {state?.fieldErrors?.content && (
          <p id="content-error" className="mt-1 text-sm text-red-600">
            {state.fieldErrors.content[0]}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="priority" className="block text-sm font-medium">
          Priority
        </label>
        <select
          id="priority"
          name="priority"
          defaultValue="normal"
          className="mt-1 block w-full rounded-md border-gray-300"
        >
          <option value="low">Low</option>
          <option value="normal">Normal</option>
          <option value="high">High</option>
        </select>
      </div>

      <div>
        <label htmlFor="attachments" className="block text-sm font-medium">
          Attachments (optional)
        </label>
        <input
          id="attachments"
          name="attachments"
          type="file"
          multiple
          accept=".pdf,.doc,.docx,.txt,.jpg,.png"
          className="mt-1 block w-full"
        />
      </div>

      {state?.error && !state.success && (
        <div className="rounded-md bg-red-50 p-4">
          <p className="text-sm text-red-800">{state.error}</p>
        </div>
      )}

      {state?.success && (
        <div className="rounded-md bg-green-50 p-4">
          <p className="text-sm text-green-800">Message sent successfully!</p>
        </div>
      )}

      <SubmitButton />
    </form>
  );
}

function SubmitButton() {
  const { pending } = useFormStatus();
  
  return (
    <button
      type="submit"
      disabled={pending}
      className="flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
    >
      {pending ? 'Sending...' : 'Send Message'}
    </button>
  );
}

Advanced Patterns with next-safe-action

For complex applications, next-safe-action provides additional type safety and middleware capabilities:

import { createSafeActionClient } from 'next-safe-action';

const actionClient = createSafeActionClient({
  // Global middleware for auth, logging, etc.
  middleware: async ({ ctx }) => {
    const session = await getServerSession();
    if (!session?.user) {
      throw new Error('Unauthorized');
    }
    return { userId: session.user.id };
  },
  
  // Global error handler
  handleServerErrorLog: (error, { clientInput, ctx, metadata }) => {
    console.error('Server action error:', {
      error: error.message,
      input: clientInput,
      userId: ctx?.userId,
      action: metadata.actionName
    });
  }
});

export const sendMessageAction = actionClient
  .schema(messageSchema)
  .action(async ({ parsedInput, ctx }) => {
    // parsedInput is fully typed and validated
    // ctx.userId is available from middleware
    
    const messageId = await messageService.send({
      ...parsedInput,
      senderId: ctx.userId
    });
    
    revalidatePath('/messages');
    return { messageId };
  });
'use client';

import { useAction } from 'next-safe-action/hooks';

export function SafeMessageForm() {
  const { execute, result, isExecuting } = useAction(sendMessageAction);
  
  const handleSubmit = (formData: FormData) => {
    execute({
      recipientId: formData.get('recipientId') as string,
      subject: formData.get('subject') as string,
      content: formData.get('content') as string,
      priority: formData.get('priority') as 'low' | 'normal' | 'high'
    });
  };

  return (
    <form action={handleSubmit}>
      {/* Form fields */}
      
      {result?.validationErrors && (
        <div>
          {Object.entries(result.validationErrors).map(([field, errors]) => (
            <p key={field} className="text-red-600">
              {field}: {errors?.join(', ')}
            </p>
          ))}
        </div>
      )}
      
      {result?.serverError && (
        <p className="text-red-600">{result.serverError}</p>
      )}
      
      <button type="submit" disabled={isExecuting}>
        {isExecuting ? 'Sending...' : 'Send Message'}
      </button>
    </form>
  );
}

As a reminder to myself: performance & Security Considerations

Execution Context:

  • Server Actions run in Node.js environment with full server capabilities
  • Each action creates a new execution context per invocation
  • Memory usage scales with concurrent executions

Security Model:

  • Built-in CSRF protection via cryptographic tokens
  • Actions are automatically bound to the originating page
  • FormData is sanitized but manual validation is still required

Caching Strategy:

  • Use revalidatePath() for targeted cache invalidation
  • Consider revalidateTag() for more granular control
  • Actions don’t participate in static generation

Error Boundaries:

  • Server errors in actions don’t trigger client error boundaries
  • Use proper error handling within actions
  • Return structured error objects for client consumption
Last updated
© 2025, Devpulsion.
Exposio 1.0.0#82a06ca | About