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.