Next.js Server Actions are async functions that run on the server, callable directly from React components — no API route needed. They handle form submissions, mutations, and data fetching without a separate client/server boundary. useOptimistic provides instant UI feedback while the action runs. next-safe-action adds Zod validation and typed error returns to the untyped default API. Claude Code generates server actions, form components with progressive enhancement, optimistic update patterns, and the revalidation logic that keeps UI in sync.
CLAUDE.md for Server Actions
## Server Actions Stack
- Next.js 15 App Router with React 19
- Server Actions for all mutations (no separate API routes for UI operations)
- Validation: next-safe-action + Zod (typed actions with error handling)
- Optimistic UI: useOptimistic for instant feedback on create/update/delete
- Revalidation: revalidatePath() after mutations, revalidateTag() for granular cache
- Auth: check session in every action — never trust client-side auth state
- Error handling: return { error } objects, never throw in Server Actions for form errors
Basic Server Action
// app/orders/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { z } from 'zod';
import { db } from '@/lib/db';
import { getAuthSession } from '@/lib/auth';
import { orders } from '@/lib/schema';
const CreateOrderSchema = z.object({
customerId: z.string().min(1),
items: z.array(z.object({
productId: z.string(),
quantity: z.number().int().min(1).max(100),
})).min(1),
notes: z.string().max(500).optional(),
});
export async function createOrderAction(formData: FormData) {
// ALWAYS authenticate in Server Actions
const session = await getAuthSession();
if (!session) redirect('/login');
// Parse and validate
const raw = {
customerId: formData.get('customerId'),
items: JSON.parse(formData.get('items') as string ?? '[]'),
notes: formData.get('notes') || undefined,
};
const parsed = CreateOrderSchema.safeParse(raw);
if (!parsed.success) {
return { error: parsed.error.flatten().fieldErrors };
}
try {
const order = await db.insert(orders).values({
id: crypto.randomUUID(),
userId: session.user.id,
customerId: parsed.data.customerId,
status: 'pending',
notes: parsed.data.notes,
createdAt: new Date(),
updatedAt: new Date(),
}).returning();
// Invalidate cached data
revalidatePath('/orders');
revalidatePath(`/customers/${parsed.data.customerId}`);
return { success: true, orderId: order[0].id };
} catch (e) {
return { error: { _form: ['Failed to create order. Please try again.'] } };
}
}
next-safe-action for Type Safety
// lib/safe-action.ts
import { createSafeActionClient } from 'next-safe-action';
import { z } from 'zod';
import { getAuthSession } from '@/lib/auth';
// Base client — no auth required
export const actionClient = createSafeActionClient();
// Auth-required client — used for protected actions
export const authActionClient = createSafeActionClient({
async middleware() {
const session = await getAuthSession();
if (!session) throw new Error('Unauthorized');
return { userId: session.user.id };
},
});
// app/orders/actions.ts — typed actions with next-safe-action
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { authActionClient } from '@/lib/safe-action';
const UpdateOrderSchema = z.object({
orderId: z.string().uuid(),
status: z.enum(['processing', 'shipped', 'delivered', 'cancelled']),
trackingNumber: z.string().optional(),
});
// Fully typed — input schema, output type, middleware context
export const updateOrderStatusAction = authActionClient
.schema(UpdateOrderSchema)
.action(async ({ parsedInput, ctx }) => {
const { orderId, status, trackingNumber } = parsedInput;
const { userId } = ctx; // From middleware
// Verify ownership
const order = await db.query.orders.findFirst({
where: and(eq(orders.id, orderId), eq(orders.userId, userId)),
});
if (!order) throw new Error('Order not found');
await db.update(orders)
.set({ status, trackingNumber, updatedAt: new Date() })
.where(eq(orders.id, orderId));
revalidatePath(`/orders/${orderId}`);
revalidatePath('/orders');
return { orderId, newStatus: status };
});
useOptimistic for Instant Feedback
// app/orders/[id]/components/OrderActions.tsx
'use client';
import { useOptimistic, useTransition } from 'react';
import { updateOrderStatusAction } from '../actions';
import type { Order } from '@/lib/types';
interface OrderActionsProps {
order: Order;
}
type OptimisticOrder = Order & { isPending?: boolean };
export function OrderActions({ order }: OrderActionsProps) {
const [isPending, startTransition] = useTransition();
// Optimistic state: shows updated status immediately
const [optimisticOrder, addOptimisticUpdate] = useOptimistic<OptimisticOrder, string>(
order,
(current, newStatus) => ({ ...current, status: newStatus, isPending: true })
);
const handleStatusChange = (newStatus: Order['status']) => {
startTransition(async () => {
// Update UI immediately
addOptimisticUpdate(newStatus);
// Call server action
const result = await updateOrderStatusAction({
orderId: order.id,
status: newStatus,
});
if (result?.serverError) {
console.error('Failed to update:', result.serverError);
// Optimistic state reverts automatically on error
}
});
};
return (
<div className="order-actions">
<div className="status-badge" data-pending={optimisticOrder.isPending}>
{optimisticOrder.status}
{optimisticOrder.isPending && <span className="updating">Updating...</span>}
</div>
{order.status === 'pending' && (
<button onClick={() => handleStatusChange('processing')} disabled={isPending}>
Start Processing
</button>
)}
{order.status === 'processing' && (
<button onClick={() => handleStatusChange('shipped')} disabled={isPending}>
Mark Shipped
</button>
)}
</div>
);
}
Progressive Enhancement Form
// app/orders/new/page.tsx — works with and without JavaScript
import { createOrderAction } from '../actions';
export default function NewOrderPage() {
return (
<form action={createOrderAction}>
{/* Works as plain HTML form without JS */}
<div className="field">
<label htmlFor="customerId">Customer ID</label>
<input
id="customerId"
name="customerId"
type="text"
required
placeholder="cust_..."
/>
</div>
{/* Hidden JSON for items (JS enhances this to a dynamic list) */}
<input
type="hidden"
name="items"
value='[{"productId":"prod_default","quantity":1}]'
/>
<textarea name="notes" placeholder="Order notes (optional)" maxLength={500} />
<button type="submit">Create Order</button>
</form>
);
}
// Enhanced version with useFormState for error display
'use client';
import { useActionState } from 'react';
import { createOrderAction } from '../actions';
const initialState = { error: null, success: false };
export function CreateOrderForm() {
const [state, formAction, isPending] = useActionState(createOrderAction, initialState);
return (
<form action={formAction}>
{state?.error?._form && (
<div role="alert" className="form-error">
{state.error._form.join(', ')}
</div>
)}
<div className="field">
<label htmlFor="customerId">Customer ID</label>
<input
id="customerId"
name="customerId"
aria-invalid={!!state?.error?.customerId}
aria-describedby={state?.error?.customerId ? 'customerId-error' : undefined}
/>
{state?.error?.customerId && (
<p id="customerId-error" className="field-error">
{state.error.customerId[0]}
</p>
)}
</div>
<button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create Order'}
</button>
{state?.success && (
<p className="success">Order created! ID: {state.orderId}</p>
)}
</form>
);
}
Revalidation Patterns
// app/orders/actions.ts — granular cache invalidation
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
// Tag-based invalidation (more granular than path)
export async function deleteOrderAction(orderId: string) {
await db.delete(orders).where(eq(orders.id, orderId));
// Invalidate specific cache tags
revalidateTag(`order-${orderId}`); // This order's detail page
revalidateTag('orders-list'); // All order list pages
revalidateTag(`customer-${customerId}`); // Customer's order history
}
// Tag data fetching (Next.js fetch with tags)
export async function getOrder(id: string) {
return fetch(`/api/orders/${id}`, {
next: {
tags: [`order-${id}`],
revalidate: 60, // Also time-based
},
});
}
For the App Router layouts and Server Components that host these forms and actions, the Next.js App Router guide covers RSC patterns and suspense boundaries. For the Drizzle ORM queries called inside these actions, the Drizzle ORM guide covers transaction patterns. The Claude Skills 360 bundle includes Server Actions skill sets covering next-safe-action, useOptimistic patterns, and progressive enhancement. Start with the free tier to try Server Action generation.