Claude Code for Next.js Server Actions: Forms, Mutations, and Optimistic UI — Claude Skills 360 Blog
Blog / Frontend / Claude Code for Next.js Server Actions: Forms, Mutations, and Optimistic UI
Frontend

Claude Code for Next.js Server Actions: Forms, Mutations, and Optimistic UI

Published: November 25, 2026
Read time: 8 min read
By: Claude Skills 360

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.

Keep Reading

Frontend

Claude Code for Chart.js Advanced: Custom Plugins and Mixed Charts

Advanced Chart.js patterns with Claude Code — chart.register() for tree-shaking, mixed chart types combining bar and line, custom plugin API with beforeDraw and afterDatasetsDraw hooks, ScriptableContext for computed colors, ChartDataLabels plugin for value labels, chartjs-plugin-zoom for pan and zoom, custom gradient fills via ctx.createLinearGradient, ChartJS annotation plugin for threshold lines, streaming data with chartjs-plugin-streaming, and react-chartjs-2 with useRef and chart instance.

6 min read Jun 27, 2027
Frontend

Claude Code for Nivo: Rich SVG and Canvas Charts

Build rich data visualizations with Nivo and Claude Code — ResponsiveLine and ResponsiveBar for adaptive charts, ResponsiveHeatMap for matrix data, ResponsiveTreeMap for hierarchal data, ResponsiveSunburst for nested proportions, ResponsiveChord for relationship diagrams, ResponsiveCalendar for activity heat maps, ResponsiveNetwork for force graphs, NivoTheme for consistent styling, tooltip customization with sliceTooltip, and motion config for spring animations.

6 min read Jun 26, 2027
Frontend

Claude Code for Victory Charts: React Native and Web Charts

Build cross-platform charts with Victory and Claude Code — VictoryChart, VictoryLine, VictoryBar, and VictoryScatter for web and React Native, VictoryPie for donut charts, VictoryArea for stacked areas, VictoryAxis for custom axes, VictoryTooltip and VictoryVoronoiContainer for hover tooltips, VictoryBrushContainer for range selection, VictoryZoomContainer for pan and zoom, VictoryLegend for series labels, custom theme with VictoryTheme, and VictoryStack for grouped bars.

6 min read Jun 25, 2027

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free