ling, and cache invalidation strategies.
Step 1: Implement Server Actions for Internal Mutations
Server Actions are ideal when a client component needs to trigger a server-side mutation without managing HTTP state. The framework handles serialization, CSRF protection, and error propagation automatically.
// src/actions/inventory.ts
'use server'
import { revalidateTag } from 'next/cache'
import { db } from '@/lib/database'
import { validateReservation } from '@/lib/validators'
export async function reserveStock(sku: string, quantity: number): Promise<{ success: boolean; reservationId: string }> {
const validation = validateReservation({ sku, quantity })
if (!validation.success) {
throw new Error(validation.error.message)
}
const result = await db.inventory.reserve({
product_sku: sku,
reserved_qty: quantity,
expires_at: new Date(Date.now() + 15 * 60 * 1000)
})
if (!result) {
throw new Error('Insufficient stock available')
}
revalidateTag('inventory-status')
return { success: true, reservationId: result.id }
}
// src/components/checkout/stock-reservation.tsx
'use client'
import { useActionState } from 'react'
import { reserveStock } from '@/actions/inventory'
export function StockReservation({ sku }: { sku: string }) {
const [state, formAction, isPending] = useActionState(
async (_prevState: any, formData: FormData) => {
const qty = Number(formData.get('quantity'))
try {
return await reserveStock(sku, qty)
} catch (err) {
return { error: (err as Error).message }
}
},
null
)
return (
<form action={formAction} className="flex gap-2">
<input type="number" name="quantity" min="1" defaultValue="1" required />
<button type="submit" disabled={isPending}>
{isPending ? 'Reserving...' : 'Reserve Stock'}
</button>
{state?.error && <p className="text-red-500">{state.error}</p>}
{state?.success && <p className="text-green-600">Reserved: {state.reservationId}</p>}
</form>
)
}
Architecture Rationale:
- Direct invocation eliminates HTTP overhead and manual response parsing.
useActionState provides built-in loading states and error boundaries.
revalidateTag ensures cache consistency without full page reloads.
- Arguments are restricted to serializable primitives, preventing accidental leakage of complex objects.
Step 2: Implement Route Handlers for External Integrations
When external systems need to communicate with your application, HTTP contracts become mandatory. Route Handlers provide method routing, header inspection, and middleware compatibility.
// src/app/api/webhooks/payment/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { verifyWebhookSignature } from '@/lib/crypto'
import { processPaymentEvent } from '@/services/payments'
export async function POST(request: NextRequest) {
const signature = request.headers.get('x-payment-signature')
if (!signature) {
return NextResponse.json({ error: 'Missing signature' }, { status: 401 })
}
const payload = await request.text()
const isValid = verifyWebhookSignature(payload, signature)
if (!isValid) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 403 })
}
try {
const event = JSON.parse(payload)
await processPaymentEvent(event)
return NextResponse.json({ received: true }, { status: 200 })
} catch (error) {
return NextResponse.json(
{ error: 'Processing failed', details: (error as Error).message },
{ status: 500 }
)
}
}
Architecture Rationale:
- Explicit signature verification prevents spoofed requests.
request.text() preserves raw payload integrity for cryptographic verification.
- Structured error responses maintain contract consistency for external callers.
- Route Handlers naturally integrate with Next.js middleware for IP filtering, rate limiting, or CORS policies.
Pitfall Guide
1. Treating Server Actions as Public Endpoints
Explanation: Developers sometimes expose Server Actions to external services, assuming they work like HTTP endpoints. They do not support HTTP methods, lack CORS configuration, and cannot be called outside the Next.js runtime.
Fix: Reserve Server Actions for internal React component communication. Use Route Handlers for any external integration.
2. Ignoring Serialization Boundaries
Explanation: Server Actions automatically serialize arguments and return values. Passing Date objects, Maps, Sets, or class instances causes silent failures or runtime errors.
Fix: Pass primitives (strings, numbers, booleans) or plain objects. Reconstruct complex types server-side after receiving the serialized data.
Explanation: Direct function calls do not automatically validate payloads. Assuming type safety eliminates the need for runtime validation leaves the application vulnerable to malformed data.
Fix: Always run Zod, Valibot, or a similar schema validator at the entry point of every Server Action before database operations.
4. Forgetting Cache Invalidation After Mutations
Explanation: Server Actions modify data but do not automatically update the React cache. Stale UI persists until the next full navigation or manual refresh.
Fix: Explicitly call revalidatePath() or revalidateTag() immediately after successful mutations. Prefer tag-based invalidation for granular control.
5. Mixing Business Logic with Route Handlers
Explanation: Placing complex validation, database queries, and third-party API calls directly inside route.ts files creates tightly coupled, untestable code.
Fix: Keep Route Handlers as thin adapters. Extract business logic to service layers (src/services/) and unit test them independently.
6. Assuming CSRF Protection is Universal
Explanation: Server Actions include automatic CSRF protection via Next.js internals. Route Handlers do not. Assuming all server endpoints are equally protected leaves API routes vulnerable to cross-site request forgery.
Fix: Implement CSRF tokens, SameSite cookie policies, or origin validation for any Route Handler that modifies state.
7. Inconsistent Error Shapes Across Boundaries
Explanation: Server Actions throw exceptions that bubble up to client components. Route Handlers return HTTP status codes with JSON bodies. Mixing these patterns without a unified error contract breaks client-side error handling.
Fix: Standardize error responses. Wrap Server Action throws in try/catch blocks that return { error: string } objects, matching the shape expected by Route Handler responses.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Client-side form submission | Server Actions | Built-in CSRF, automatic serialization, seamless React integration | Low complexity, minimal boilerplate |
| Third-party webhook | Route Handlers | Requires HTTP method routing, signature verification, raw payload access | Moderate complexity, requires crypto/validation setup |
| Public REST API | Route Handlers | Needs CORS, rate limiting, explicit status codes, external accessibility | Higher complexity, requires middleware & monitoring |
| Internal data mutation | Server Actions | Direct invocation, type-safe, automatic cache invalidation | Low complexity, optimized for React ecosystem |
| Rate-limited endpoint | Route Handlers | Middleware integration, IP-based tracking, sliding window support | Moderate complexity, requires Redis/Upstash integration |
Configuration Template
// src/lib/api-contract.ts
import { z } from 'zod'
export const ApiErrorSchema = z.object({
error: z.string(),
code: z.enum(['VALIDATION', 'AUTH', 'NOT_FOUND', 'INTERNAL']),
details: z.string().optional()
})
export type ApiError = z.infer<typeof ApiErrorSchema>
export function createErrorResponse(error: unknown, code: ApiError['code'] = 'INTERNAL'): Response {
const message = error instanceof Error ? error.message : 'Unknown error occurred'
return Response.json(
{ error: message, code, details: process.env.NODE_ENV === 'development' ? message : undefined },
{ status: code === 'AUTH' ? 401 : code === 'NOT_FOUND' ? 404 : 500 }
)
}
// src/actions/base.ts
'use server'
import { revalidateTag } from 'next/cache'
import { ApiErrorSchema } from '@/lib/api-contract'
export async function withCacheInvalidation<T>(
tag: string,
action: () => Promise<T>
): Promise<T> {
const result = await action()
revalidateTag(tag)
return result
}
Quick Start Guide
- Initialize the project: Run
npx create-next-app@latest my-app --typescript --app --tailwind and navigate into the directory.
- Create the action boundary: Add
src/actions/ directory. Create a file with 'use server' directive at the top. Define your mutation function using serializable parameters.
- Create the route boundary: Add
src/app/api/ directory. Create a route.ts file. Export HTTP method handlers (GET, POST, etc.) using NextRequest and NextResponse.
- Wire the client: Import the Server Action into a
'use client' component. Use useActionState or direct async calls. For Route Handlers, use fetch with explicit error checking.
- Validate and test: Run
npm run dev. Test Server Actions through the UI. Test Route Handlers using curl or Postman. Verify cache invalidation and error handling match expectations.