Skip to content

Server Actions

Last Updated: 2025-01-22

Server Actions provide a type-safe, streamlined way to handle data mutations and queries in Next.js 15. This guide covers the Server Actions implementation in AccessALI.


Overview

Server Actions are asynchronous functions that run on the server and can be called directly from React components. They provide automatic authentication, validation, and type safety.

Key Features

  • Type-safe - Full TypeScript support from client to server
  • Built-in authentication - NextAuth.js integration
  • Progressive enhancement - Works without JavaScript
  • Optimistic updates - Instant UI feedback
  • Automatic revalidation - Cache invalidation built-in

Architecture

graph LR
    Client[Client Component] -->|Call| Action[Server Action]
    Action -->|Auth Check| Auth[NextAuth.js]
    Action -->|Validate| Zod[Zod Schema]
    Action -->|Execute| UseCase[Use Case Layer]
    UseCase -->|Data Access| Repo[Repository Layer]
    Action -->|Revalidate| Cache[Next.js Cache]
    Action -->|Return| Client

Server Action Structure

Basic Pattern

// app/actions/feature-name.ts
'use server'

import { auth } from '@/lib/auth'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
import { useCaseFunction } from '@/lib/use-cases/feature-name'

/**
 * Action description
 *
 * @param formData - Form data or parameters
 * @returns Result object
 */
export async function actionName(formData: FormData) {
  // 1. Authenticate
  const session = await auth()
  if (!session?.user?.id) {
    throw new Error('Unauthorized')
  }

  // 2. Validate input
  const schema = z.object({
    field: z.string().min(1),
  })

  const validated = schema.parse({
    field: formData.get('field'),
  })

  // 3. Execute business logic
  const result = await useCaseFunction(session.user.id, validated)

  // 4. Revalidate cache
  revalidatePath('/path')

  // 5. Return result
  return result
}

Dashboard Actions

getDashboardData

Fetch complete dashboard data for authenticated user.

Source: src/app/actions/dashboard.ts:12-40

// app/actions/dashboard.ts
'use server'

import { auth } from '@/lib/auth'
import { getDashboardDataUseCase } from '@/lib/use-cases/dashboard'
import type { DashboardData } from '@/lib/types'
import { logger } from '@/lib/logger'

/**
 * Get dashboard data for the authenticated user
 *
 * Server Action that fetches all data needed for the dashboard page:
 * - User profile with property count
 * - Properties with current milestones and pending payments
 * - Urgent notifications
 * - Recent messages
 * - Quick action badge counts
 *
 * @returns {Promise<DashboardData>} Complete dashboard data
 * @throws {Error} If user is not authenticated
 */
export async function getDashboardData(): Promise<DashboardData> {
  try {
    // 1. Check authentication using NextAuth.js
    const session = await auth()

    if (!session?.user?.id) {
      throw new Error('Unauthorized: You must be logged in to access dashboard data')
    }

    // 2. Extract user ID from session
    const userId = session.user.id

    // 3. Call use case layer to fetch dashboard data
    // Use case orchestrates parallel fetching from multiple repositories
    const dashboardData = await getDashboardDataUseCase(userId)

    return dashboardData
  } catch (error: unknown) {
    const err = error instanceof Error ? error : new Error(String(error))
    logger.error('Error fetching dashboard data', err)

    // Re-throw authentication errors
    if (err.message.includes('Unauthorized')) {
      throw err
    }

    throw new Error(`Failed to fetch dashboard data: ${err.message}`)
  }
}

Usage in Server Component:

// app/dashboard/page.tsx
import { getDashboardData } from '@/app/actions/dashboard'

export default async function DashboardPage() {
  const data = await getDashboardData()

  return (
    <div>
      <h1>Welcome, {data.user.firstName}!</h1>
      <PropertyList properties={data.properties} />
    </div>
  )
}

Usage in Client Component:

'use client'

import { getDashboardData } from '@/app/actions/dashboard'
import { useEffect, useState } from 'react'

export function DashboardClient() {
  const [data, setData] = useState(null)

  useEffect(() => {
    getDashboardData()
      .then(setData)
      .catch(console.error)
  }, [])

  if (!data) return <div>Loading...</div>

  return <div>{/* Render dashboard */}</div>
}

searchProperties

Search user's properties with debounced input.

Source: src/app/actions/dashboard.ts:42-110

// app/actions/dashboard.ts
'use server'

import { auth } from '@/lib/auth'
import { searchPropertiesByQuery } from '@/lib/repositories/property-repository'
import { searchQuerySchema } from '@/lib/validators/property'
import type { PropertySearchResult } from '@/lib/types'
import { logger } from '@/lib/logger'

/**
 * Search properties by query string
 *
 * Searches across project name, unit number, block, and lot fields.
 * Returns matching properties with highlighted fields.
 *
 * @param {string} query - Search query string
 * @returns {Promise<PropertySearchResult[]>} Array of matching properties with highlighted fields
 * @throws {Error} If user is not authenticated or query is invalid
 */
export async function searchProperties(
  query: string
): Promise<PropertySearchResult[]> {
  try {
    // 1. Check authentication using NextAuth.js
    const session = await auth()

    if (!session?.user?.id) {
      throw new Error('Unauthorized: You must be logged in to search properties')
    }

    // 2. Extract user ID from session
    const userId = session.user.id

    // 3. Handle empty query - return empty array immediately
    if (!query || query.trim().length === 0) {
      return []
    }

    // 4. Validate query input using Zod schema
    const validationResult = searchQuerySchema.safeParse(query)

    if (!validationResult.success) {
      const errorMessages = validationResult.error.errors.map((err) => err.message)
      const firstError = errorMessages[0] || 'Invalid search query'
      throw new Error(firstError)
    }

    // 5. Perform search with validated query
    // Repository returns properties with matchedFields array
    const properties = await searchPropertiesByQuery(
      validationResult.data,
      userId,
      3 // Max 3 results for quick search
    )

    return properties
  } catch (error: unknown) {
    const err = error instanceof Error ? error : new Error(String(error))
    logger.error('Error searching properties', err)

    // Re-throw authentication/validation errors
    if (
      err.message.includes('Unauthorized') ||
      err.message.includes('Invalid')
    ) {
      throw err
    }

    throw new Error(`Failed to search properties: ${err.message}`)
  }
}

Usage with Debounce:

'use client'

import { searchProperties } from '@/app/actions/dashboard'
import { useDebouncedCallback } from 'use-debounce'
import { useState } from 'react'

export function SearchBar() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState(null)

  const debouncedSearch = useDebouncedCallback(
    async (searchQuery: string) => {
      const data = await searchProperties(searchQuery)
      setResults(data)
    },
    300
  )

  return (
    <input
      type="text"
      value={query}
      onChange={(e) => {
        setQuery(e.target.value)
        debouncedSearch(e.target.value)
      }}
    />
  )
}

Authentication Actions

Send magic link email for passwordless authentication with rate limiting.

Source: src/app/actions/auth.ts:260-355

// app/actions/auth.ts
'use server'

import { revalidatePath } from 'next/cache'
import { checkRateLimit } from '@/lib/services/rate-limit-service'
import { sendMagicLinkEmail } from '@/lib/services/email-service'
import { magicLinkEmailSchema } from '@/lib/validators/auth'
import { logger } from '@/lib/logger'

/**
 * Send magic link for passwordless authentication
 *
 * Features:
 * - Email validation with Zod
 * - Rate limiting (5 attempts per 15 minutes)
 * - Secure token generation
 * - Email sending via Resend
 *
 * @param {string} email - User's email address
 * @returns {Promise<ActionResult>} Success or error result with retry timing
 */
export async function sendMagicLink(
  email: string
): Promise<ActionResult<{ message: string }>> {
  try {
    // 1. Validate email format using Zod schema
    const validatedEmail = magicLinkEmailSchema.parse(email)

    // 2. Check rate limit (5 requests per 15 minutes per email)
    const rateLimitConfig = {
      maxAttempts: 5,
      windowMs: 15 * 60 * 1000, // 15 minutes
    }

    const rateLimitResult = await checkRateLimit(
      validatedEmail,
      rateLimitConfig
    )

    if (!rateLimitResult.success) {
      const resetDate = new Date(rateLimitResult.reset!)
      return {
        success: false,
        error: 'Too many requests',
        message: `Too many login attempts. Please try again at ${resetDate.toLocaleTimeString()}.`,
        retryAfter: resetDate,
      }
    }

    // 3. Send magic link email (creates token, stores in DB, sends email)
    const result = await sendMagicLinkEmail(validatedEmail)

    if (!result.success) {
      return {
        success: false,
        error: result.error || 'Failed to send magic link',
        message: 'Unable to send magic link. Please try again.',
      }
    }

    return {
      success: true,
      data: {
        message: `Magic link sent to ${validatedEmail}. Check your inbox.`,
      },
    }
  } catch (error: unknown) {
    const err = error instanceof Error ? error : new Error(String(error))
    logger.error('Error sending magic link', err)

    if (err instanceof z.ZodError) {
      return {
        success: false,
        error: 'Invalid email',
        message: err.errors[0].message,
      }
    }

    return {
      success: false,
      error: 'Internal error',
      message: 'Failed to send magic link. Please try again later.',
    }
  }
}

Rate Limit Configuration:

Action Max Attempts Window Key
Magic Link 5 15 min Email
Account Activation 3 15 min Email
Password Reset 3 15 min Email

Verify magic link token and create session.

Source: src/app/actions/auth.ts:357-443

/**
 * Verify magic link token and authenticate user
 *
 * Security features:
 * - Token validation and expiration check
 * - Secure session creation with HTTP-only cookies
 * - Automatic token cleanup after use
 * - Cache revalidation for auth-protected pages
 *
 * @param {string} token - Magic link token from URL
 * @returns {Promise<ActionResult>} Session creation result with redirect URL
 */
export async function verifyMagicLink(
  token: string
): Promise<ActionResult<{ redirectUrl: string }>> {
  try {
    // 1. Validate token format
    if (!token || token.length < 32) {
      return {
        success: false,
        error: 'Invalid token',
        message: 'This magic link is invalid.',
      }
    }

    // 2. Verify token and create session
    const result = await verifyMagicLinkToken(token)

    if (!result.success || !result.sessionToken) {
      return {
        success: false,
        error: result.error || 'Token verification failed',
        message: result.message || 'This magic link has expired or is invalid.',
      }
    }

    // 3. SECURITY: Set token as HTTP-only cookie instead of returning in response
    await setSessionCookie(result.sessionToken)

    // 4. Revalidate auth-protected pages
    revalidatePath('/dashboard')
    revalidatePath('/properties')
    revalidatePath('/payments')
    revalidatePath('/documents')

    return {
      success: true,
      data: {
        redirectUrl: '/dashboard',
      },
    }
  } catch (error: unknown) {
    const err = error instanceof Error ? error : new Error(String(error))
    logger.error('Error verifying magic link', err)

    return {
      success: false,
      error: 'Verification failed',
      message: 'Unable to verify magic link. Please request a new one.',
    }
  }
}

Security Pattern:

  • Token stored as HTTP-only cookie (not in localStorage or response body)
  • Prevents XSS attacks from accessing session token
  • Automatic expiration handled by NextAuth.js
  • Path revalidation ensures fresh data after authentication

Property Actions

getPropertyDetailsAction

Fetch detailed property information with ownership validation.

Source: src/app/actions/property.ts:35-121

// app/actions/property.ts
'use server'

import { auth } from '@/lib/auth'
import { getPropertyById, validatePropertyOwnership } from '@/lib/repositories/property-repository'
import { getPropertyDetailsSchema } from '@/lib/validators/property'
import type { PropertyDetails } from '@/lib/types'
import { logger } from '@/lib/logger'

/**
 * Get detailed property information
 *
 * Security:
 * - Validates user authentication
 * - Validates property ownership before returning data
 * - Input validation with Zod
 *
 * @param {string} propertyId - Property UUID
 * @returns {Promise<PropertyDetails>} Complete property details with milestones and payments
 * @throws {Error} If user is not authenticated or doesn't own property
 */
export async function getPropertyDetailsAction(
  propertyId: string
): Promise<PropertyDetails> {
  try {
    // 1. Validate input parameters using Zod
    const validatedInput = getPropertyDetailsSchema.parse({ propertyId })

    // 2. Check authentication using NextAuth.js
    const session = await auth()

    // 3. Validate that user is authenticated
    if (!session?.user?.id) {
      throw new Error('Unauthorized: You must be logged in to view property details')
    }

    // 4. Extract user ID from session
    const userId = session.user.id

    // 5. Validate property ownership (throws if user doesn't own property)
    await validatePropertyOwnership(userId, validatedInput.propertyId)

    // 6. Fetch property details from repository
    const property = await getPropertyById(validatedInput.propertyId, userId)

    if (!property) {
      throw new Error('Property not found')
    }

    // 7. Transform Decimal types to numbers for client serialization
    // Prisma returns Decimal for financial fields, must convert for JSON
    return {
      id: property.id,
      projectName: property.projectName,
      unitNumber: property.unitNumber,
      block: property.block,
      lot: property.lot,
      phase: property.phase,
      model: property.model,
      floorArea: property.floorArea ? Number(property.floorArea) : null,
      lotArea: property.lotArea ? Number(property.lotArea) : null,
      totalContractPrice: property.totalContractPrice
        ? Number(property.totalContractPrice)
        : null,
      imageUrl: property.imageUrl,
      status: property.status,
      turnoverDate: property.turnoverDate,
      milestones: property.milestones.map((m) => ({
        ...m,
        progressPercentage: m.progressPercentage
          ? Number(m.progressPercentage)
          : 0,
      })),
      payments: property.payments.map((p) => ({
        ...p,
        amount: Number(p.amount),
        amountPaid: p.amountPaid ? Number(p.amountPaid) : null,
      })),
      documents: property.documents,
      createdAt: property.createdAt,
      updatedAt: property.updatedAt,
    }
  } catch (error: unknown) {
    const err = error instanceof Error ? error : new Error(String(error))
    logger.error('Error fetching property details', err)

    // Re-throw authentication and authorization errors
    if (
      err.message.includes('Unauthorized') ||
      err.message.includes('not found')
    ) {
      throw err
    }

    throw new Error(`Failed to fetch property details: ${err.message}`)
  }
}

Key Pattern: Decimal to Number Conversion

// ❌ Don't send Prisma Decimal directly to client
totalContractPrice: property.totalContractPrice

// ✅ Convert Decimal to number for JSON serialization
totalContractPrice: property.totalContractPrice
  ? Number(property.totalContractPrice)
  : null


Form Actions

With FormData

// app/actions/property.ts
'use server'

import { auth } from '@/lib/auth'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'

const updatePropertySchema = z.object({
  propertyId: z.string().min(1),
  projectName: z.string().min(1, 'Project name is required'),
  unitNumber: z.string().min(1, 'Unit number is required'),
})

export async function updateProperty(formData: FormData) {
  try {
    const session = await auth()
    if (!session?.user?.id) {
      return { success: false, error: 'Unauthorized' }
    }

    // Validate
    const validated = updatePropertySchema.parse({
      propertyId: formData.get('propertyId'),
      projectName: formData.get('projectName'),
      unitNumber: formData.get('unitNumber'),
    })

    // Update
    const result = await updatePropertyUseCase(
      session.user.id,
      validated.propertyId,
      validated
    )

    // Revalidate
    revalidatePath(`/properties/${validated.propertyId}`)

    return { success: true, data: result }
  } catch (error) {
    logger.error('Error updating property', error)

    if (error instanceof z.ZodError) {
      return {
        success: false,
        error: error.errors[0].message,
      }
    }

    return {
      success: false,
      error: 'Failed to update property',
    }
  }
}

Usage in Form:

'use client'

import { updateProperty } from '@/app/actions/property'
import { useFormState, useFormStatus } from 'react-dom'

function SubmitButton() {
  const { pending } = useFormStatus()
  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Saving...' : 'Save'}
    </button>
  )
}

export function PropertyForm({ property }) {
  const [state, formAction] = useFormState(updateProperty, null)

  return (
    <form action={formAction}>
      <input type="hidden" name="propertyId" value={property.id} />

      <input
        name="projectName"
        defaultValue={property.projectName}
        required
      />

      <input
        name="unitNumber"
        defaultValue={property.unitNumber}
        required
      />

      {state?.error && <div>{state.error}</div>}

      <SubmitButton />
    </form>
  )
}

With JSON Payload

// app/actions/milestone.ts
'use server'

import { auth } from '@/lib/auth'
import { revalidatePath } from 'next/cache'

export async function updateMilestone(milestoneId: string, progress: number) {
  try {
    const session = await auth()
    if (!session?.user?.id) {
      throw new Error('Unauthorized')
    }

    // Validate progress
    if (progress < 0 || progress > 100) {
      throw new Error('Progress must be between 0 and 100')
    }

    // Update
    const result = await updateMilestoneUseCase(
      session.user.id,
      milestoneId,
      { progress }
    )

    // Revalidate
    revalidatePath(`/properties/${result.propertyId}`)

    return { success: true, data: result }
  } catch (error) {
    logger.error('Error updating milestone', error)
    return { success: false, error: error.message }
  }
}

Usage:

'use client'

import { updateMilestone } from '@/app/actions/milestone'

export function MilestoneProgress({ milestone }) {
  async function handleUpdate(progress: number) {
    const result = await updateMilestone(milestone.id, progress)

    if (result.success) {
      toast.success('Milestone updated')
    } else {
      toast.error(result.error)
    }
  }

  return (
    <Slider
      value={milestone.progress}
      onChange={handleUpdate}
      min={0}
      max={100}
    />
  )
}

Optimistic Updates

Pattern

'use client'

import { updateMilestone } from '@/app/actions/milestone'
import { useOptimistic } from 'react'

export function MilestoneList({ initialMilestones }) {
  const [milestones, addOptimisticMilestone] = useOptimistic(
    initialMilestones,
    (state, newMilestone) => {
      return state.map((m) =>
        m.id === newMilestone.id ? newMilestone : m
      )
    }
  )

  async function handleUpdate(id: string, progress: number) {
    // Optimistic update
    addOptimisticMilestone({ id, progress })

    // Server update
    const result = await updateMilestone(id, progress)

    if (!result.success) {
      // Revert on error
      toast.error(result.error)
    }
  }

  return (
    <div>
      {milestones.map((milestone) => (
        <MilestoneCard
          key={milestone.id}
          milestone={milestone}
          onUpdate={handleUpdate}
        />
      ))}
    </div>
  )
}

Cache Revalidation

revalidatePath

Revalidate specific page:

import { revalidatePath } from 'next/cache'

export async function updateProperty(data) {
  // Update logic

  // Revalidate specific property page
  revalidatePath(`/properties/${data.propertyId}`)

  // Revalidate dashboard
  revalidatePath('/dashboard')

  return result
}

revalidateTag

Revalidate by cache tag:

import { revalidateTag } from 'next/cache'

export async function createPayment(data) {
  // Create payment

  // Revalidate all pages tagged with 'payments'
  revalidateTag('payments')

  // Revalidate property-specific data
  revalidateTag(`property-${data.propertyId}`)

  return result
}

Redirect After Mutation

import { redirect } from 'next/navigation'

export async function createProperty(formData: FormData) {
  // Create property
  const property = await createPropertyUseCase(userId, data)

  // Redirect to new property page
  redirect(`/properties/${property.id}`)
}

Error Handling

Structured Error Response

type ActionResult<T> = {
  success: true
  data: T
} | {
  success: false
  error: string
  field?: string
}

export async function updateProperty(
  formData: FormData
): Promise<ActionResult<Property>> {
  try {
    // Logic
    return { success: true, data: result }
  } catch (error) {
    if (error instanceof ValidationError) {
      return {
        success: false,
        error: error.message,
        field: error.field,
      }
    }

    return {
      success: false,
      error: 'An unexpected error occurred',
    }
  }
}

Client-Side Error Handling

'use client'

import { updateProperty } from '@/app/actions/property'

export function PropertyForm() {
  async function handleSubmit(formData: FormData) {
    const result = await updateProperty(formData)

    if (result.success) {
      toast.success('Property updated')
    } else {
      toast.error(result.error)
    }
  }

  return <form action={handleSubmit}>{/* Form fields */}</form>
}

Testing Server Actions

Unit Test

import { getDashboardData } from '../dashboard'
import { auth } from '@/lib/auth'

jest.mock('@/lib/auth')
jest.mock('@/lib/use-cases/dashboard')

describe('getDashboardData', () => {
  it('should return dashboard data for authenticated user', async () => {
    ;(auth as jest.Mock).mockResolvedValue({
      user: { id: 'user-123' },
    })

    const result = await getDashboardData()

    expect(result.user).toBeDefined()
    expect(result.properties).toBeDefined()
  })

  it('should throw error when not authenticated', async () => {
    ;(auth as jest.Mock).mockResolvedValue(null)

    await expect(getDashboardData()).rejects.toThrow('Unauthorized')
  })
})

Best Practices

Do

  • Always check authentication first
  • Validate all inputs with Zod
  • Use TypeScript for type safety
  • Return structured error responses
  • Revalidate affected paths
  • Log errors for debugging
  • Use progressive enhancement

Don't

  • Expose sensitive data in error messages
  • Skip input validation
  • Forget to revalidate cache
  • Use server actions for GET requests
  • Return database errors directly
  • Skip authentication checks
  • Create circular dependencies

Action Patterns

Pattern 1: Simple Query

export async function getData() {
  const session = await auth()
  if (!session?.user?.id) throw new Error('Unauthorized')

  return await fetchData(session.user.id)
}

Pattern 2: Form Mutation

export async function mutateData(formData: FormData) {
  const session = await auth()
  if (!session?.user?.id) {
    return { success: false, error: 'Unauthorized' }
  }

  const validated = schema.parse(Object.fromEntries(formData))

  const result = await updateData(session.user.id, validated)

  revalidatePath('/path')

  return { success: true, data: result }
}

Pattern 3: Optimistic Update

export async function optimisticUpdate(id: string, data: any) {
  const session = await auth()
  if (!session?.user?.id) throw new Error('Unauthorized')

  // No revalidation - let client handle optimistic update
  return await updateData(id, data)
}


Next Steps

  1. Review API Routes for REST endpoints
  2. Learn about Authentication patterns
  3. Explore Use Cases documentation
  4. Understand Repositories layer