- 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