Server Actions
Next.js Server Actions implementation, patterns, and best practices for server-side operations.
Server Actions
Server Actions provide a powerful way to handle server-side operations directly from React components, offering type-safe server-side mutations with excellent developer experience.
Server Actions Overview
What are Server Actions?
Server Actions are asynchronous functions that run on the server and can be called from Client Components. They provide:
- Type Safety: End-to-end type safety from client to server
- Automatic Serialization: Built-in serialization for arguments and return values
- Progressive Enhancement: Work without JavaScript enabled
- Built-in Validation: Integration with form validation
- Optimistic Updates: Support for optimistic UI patterns
Basic Server Action Structure
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { z } from 'zod'
// Server Action for creating a product
export async function createProduct(formData: FormData) {
// Validation
const schema = z.object({
name: z.string().min(1, 'Name is required'),
sku: z.string().min(1, 'SKU is required'),
price: z.number().positive('Price must be positive'),
category_id: z.string().uuid('Invalid category ID')
})
// Parse and validate form data
const rawData = {
name: formData.get('name') as string,
sku: formData.get('sku') as string,
price: Number(formData.get('price')),
category_id: formData.get('category_id') as string
}
try {
const validatedData = schema.parse(rawData)
// Database operation
const product = await productService.create(validatedData)
// Revalidate cache
revalidatePath('/products')
return { success: true, data: product }
} catch (error) {
if (error instanceof z.ZodError) {
return {
success: false,
error: 'Validation failed',
fieldErrors: error.flatten().fieldErrors
}
}
return {
success: false,
error: 'Failed to create product'
}
}
}
Server Action Patterns
1. Form-Based Actions
Traditional Form Submission
// app/products/new/page.tsx
import { createProduct } from '@/lib/actions/products'
export default function NewProductPage() {
return (
<form action={createProduct}>
<input
name="name"
placeholder="Product name"
required
/>
<input
name="sku"
placeholder="SKU"
required
/>
<input
name="price"
type="number"
placeholder="Price"
step="0.01"
required
/>
<select name="category_id" required>
<option value="">Select category</option>
{/* Category options */}
</select>
<button type="submit">Create Product</button>
</form>
)
}
Enhanced Form with useFormState
'use client'
import { useFormState } from 'react-dom'
import { createProduct } from '@/lib/actions/products'
const initialState = {
success: false,
error: '',
fieldErrors: {}
}
export default function ProductForm() {
const [state, formAction] = useFormState(createProduct, initialState)
return (
<form action={formAction}>
<div>
<input
name="name"
placeholder="Product name"
className={state.fieldErrors?.name ? 'error' : ''}
/>
{state.fieldErrors?.name && (
<span className="error-text">{state.fieldErrors.name}</span>
)}
</div>
<div>
<input
name="price"
type="number"
placeholder="Price"
className={state.fieldErrors?.price ? 'error' : ''}
/>
{state.fieldErrors?.price && (
<span className="error-text">{state.fieldErrors.price}</span>
)}
</div>
{state.error && (
<div className="error-banner">{state.error}</div>
)}
{state.success && (
<div className="success-banner">Product created successfully!</div>
)}
<button type="submit">Create Product</button>
</form>
)
}
2. Programmatic Actions
Button Actions
// components/product-actions.tsx
import { deleteProduct, duplicateProduct } from '@/lib/actions/products'
interface ProductActionsProps {
productId: string
}
export function ProductActions({ productId }: ProductActionsProps) {
return (
<div className="product-actions">
<form action={duplicateProduct.bind(null, productId)}>
<button type="submit">Duplicate</button>
</form>
<form action={deleteProduct.bind(null, productId)}>
<button
type="submit"
className="destructive"
onClick={(e) => {
if (!confirm('Delete this product?')) {
e.preventDefault()
}
}}
>
Delete
</button>
</form>
</div>
)
}
Optimistic Actions
'use client'
import { useOptimistic } from 'react'
import { toggleProductActive } from '@/lib/actions/products'
interface Product {
id: string
name: string
isActive: boolean
}
export function ProductList({ products }: { products: Product[] }) {
const [optimisticProducts, addOptimisticProduct] = useOptimistic(
products,
(state, updatedProduct: Product) =>
state.map(p => p.id === updatedProduct.id ? updatedProduct : p)
)
async function handleToggleActive(product: Product) {
// Optimistic update
addOptimisticProduct({
...product,
isActive: !product.isActive
})
// Server action
await toggleProductActive(product.id)
}
return (
<div>
{optimisticProducts.map(product => (
<div key={product.id} className="product-item">
<span>{product.name}</span>
<button
onClick={() => handleToggleActive(product)}
className={product.isActive ? 'active' : 'inactive'}
>
{product.isActive ? 'Active' : 'Inactive'}
</button>
</div>
))}
</div>
)
}
Advanced Server Action Patterns
1. Multi-Step Actions
'use server'
export async function processOrderWorkflow(orderId: string, step: string) {
const order = await orderService.getById(orderId)
if (!order) {
return { success: false, error: 'Order not found' }
}
try {
switch (step) {
case 'validate':
await orderService.validateOrder(orderId)
await orderService.updateStatus(orderId, 'validated')
break
case 'reserve_inventory':
await inventoryService.reserveStock(order.items)
await orderService.updateStatus(orderId, 'inventory_reserved')
break
case 'process_payment':
await paymentService.processPayment(order.payment_info)
await orderService.updateStatus(orderId, 'payment_processed')
break
case 'fulfill':
await fulfillmentService.createShipment(orderId)
await orderService.updateStatus(orderId, 'fulfilled')
break
default:
return { success: false, error: 'Invalid step' }
}
revalidatePath(`/orders/${orderId}`)
return { success: true }
} catch (error) {
// Rollback logic here
await orderService.updateStatus(orderId, 'error')
return {
success: false,
error: `Failed at step: ${step}`
}
}
}
2. Batch Operations
'use server'
export async function bulkUpdateProducts(
productIds: string[],
updates: Partial<Product>
) {
const results = {
successful: [] as string[],
failed: [] as { id: string; error: string }[]
}
for (const productId of productIds) {
try {
await productService.update(productId, updates)
results.successful.push(productId)
} catch (error) {
results.failed.push({
id: productId,
error: error instanceof Error ? error.message : 'Unknown error'
})
}
}
// Revalidate affected paths
revalidatePath('/products')
return {
success: true,
results,
message: `Updated ${results.successful.length} of ${productIds.length} products`
}
}
3. File Upload Actions
'use server'
export async function uploadProductImage(
productId: string,
formData: FormData
) {
const file = formData.get('image') as File
if (!file) {
return { success: false, error: 'No file provided' }
}
// Validate file
if (!file.type.startsWith('image/')) {
return { success: false, error: 'File must be an image' }
}
if (file.size > 5 * 1024 * 1024) { // 5MB limit
return { success: false, error: 'File too large (max 5MB)' }
}
try {
// Upload to storage
const buffer = await file.arrayBuffer()
const filename = `products/${productId}/${Date.now()}-${file.name}`
const uploadResult = await storageService.upload(filename, buffer)
// Update product with image URL
await productService.update(productId, {
image_url: uploadResult.url
})
revalidatePath(`/products/${productId}`)
return {
success: true,
imageUrl: uploadResult.url
}
} catch (error) {
return {
success: false,
error: 'Failed to upload image'
}
}
}
Error Handling and Validation
Comprehensive Validation
'use server'
import { z } from 'zod'
const productSchema = z.object({
name: z.string()
.min(1, 'Name is required')
.max(100, 'Name too long'),
sku: z.string()
.min(1, 'SKU is required')
.regex(/^[A-Z0-9-]+$/, 'SKU must contain only uppercase letters, numbers, and hyphens'),
price: z.number()
.positive('Price must be positive')
.max(999999.99, 'Price too high'),
cost: z.number()
.positive('Cost must be positive')
.optional(),
category_id: z.string().uuid('Invalid category ID'),
description: z.string().optional(),
weight: z.number().positive().optional(),
dimensions: z.object({
length: z.number().positive(),
width: z.number().positive(),
height: z.number().positive()
}).optional()
})
export async function createProductAdvanced(formData: FormData) {
try {
// Parse form data
const rawData = Object.fromEntries(formData.entries())
// Handle nested objects and type conversion
const parsedData = {
...rawData,
price: Number(rawData.price),
cost: rawData.cost ? Number(rawData.cost) : undefined,
weight: rawData.weight ? Number(rawData.weight) : undefined,
dimensions: rawData.dimensions ? JSON.parse(rawData.dimensions as string) : undefined
}
// Validate
const validatedData = productSchema.parse(parsedData)
// Business logic validation
if (validatedData.cost && validatedData.cost >= validatedData.price) {
return {
success: false,
error: 'Cost must be less than price',
fieldErrors: { cost: ['Cost must be less than price'] }
}
}
// Check for duplicate SKU
const existingProduct = await productService.getBySku(validatedData.sku)
if (existingProduct) {
return {
success: false,
error: 'SKU already exists',
fieldErrors: { sku: ['SKU already exists'] }
}
}
// Create product
const product = await productService.create(validatedData)
revalidatePath('/products')
redirect(`/products/${product.id}`)
} catch (error) {
if (error instanceof z.ZodError) {
return {
success: false,
error: 'Validation failed',
fieldErrors: error.flatten().fieldErrors
}
}
// Log unexpected errors
console.error('Product creation failed:', error)
return {
success: false,
error: 'An unexpected error occurred'
}
}
}
Performance and Caching
Cache Revalidation Strategies
'use server'
export async function updateProductPrice(
productId: string,
newPrice: number
) {
try {
await productService.updatePrice(productId, newPrice)
// Revalidate specific paths
revalidatePath(`/products/${productId}`)
revalidatePath('/products') // Product list
revalidatePath('/dashboard') // Dashboard stats
// Revalidate by tag (more granular)
revalidateTag('products')
revalidateTag(`product-${productId}`)
return { success: true }
} catch (error) {
return { success: false, error: 'Failed to update price' }
}
}
Background Processing
'use server'
export async function initiateDataExport(filters: ExportFilters) {
try {
// Create export job
const exportJob = await exportService.createJob({
type: 'products',
filters,
user_id: await getCurrentUserId(),
status: 'pending'
})
// Queue background processing
await queueService.add('export-products', {
jobId: exportJob.id,
filters
})
return {
success: true,
jobId: exportJob.id,
message: 'Export started. You will be notified when complete.'
}
} catch (error) {
return {
success: false,
error: 'Failed to start export'
}
}
}
Best Practices
1. Security
- Always validate input data
- Use type-safe validation schemas
- Implement proper authorization checks
- Sanitize data before database operations
2. Error Handling
- Provide clear, actionable error messages
- Handle both validation and runtime errors
- Log errors appropriately for debugging
- Return structured error responses
3. Performance
- Use revalidatePath and revalidateTag strategically
- Implement optimistic updates for better UX
- Consider background processing for heavy operations
- Cache expensive operations appropriately
4. Testing
- Write unit tests for server actions
- Test validation logic thoroughly
- Mock external dependencies
- Test error scenarios
Server Actions provide a powerful, type-safe way to handle server-side operations with excellent developer experience and performance characteristics.