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¶
sendMagicLink¶
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 | |
| Account Activation | 3 | 15 min | |
| Password Reset | 3 | 15 min |
verifyMagicLink¶
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)
}
Related Documentation¶
- API Routes - REST API endpoints
- Authentication - NextAuth.js integration
- Use Cases - Business logic layer
- Error Handling - Error patterns
Next Steps¶
- Review API Routes for REST endpoints
- Learn about Authentication patterns
- Explore Use Cases documentation
- Understand Repositories layer