Dev Pattern Labs logo
Dev Pattern Labs Blog
Next.js 4 min readOct 24, 2026

Next.js 15 Server Actions: Complete Guide to Forms and Optimistic UI

Master Next.js 15 Server Actions, form state mutations, Zod schema validation, and instant zero-latency feedback using the React 19 useOptimistic hook.

Dev Pattern Labs Author

Dev Pattern Labs

Peer-reviewed technical tutorial · Production tested

Code editor displaying React Server Actions and modern TypeScript components
Executive Summary & Key Insight

Master Next.js 15 Server Actions, form state mutations, Zod schema validation, and instant zero-latency feedback using the React 19 useOptimistic hook. This practical guide covers step-by-step setup, real-world code snippets, security hardening, and common production edge cases.

Executive Summary & Key Takeaway (AEO)
Next.js 15 Server Actions allow developers to define asynchronous server-side functions invoked directly from client or server components without writing boilerplate API route handlers. When combined with React 19's useActionState and useOptimistic, applications achieve instant zero-latency UI updates while maintaining complete server-side data validation using Zod and automatic cache revalidation via revalidatePath.


The Paradigm Shift: Server Actions vs Traditional API Routes

Prior to Server Actions, updating a database record required:

  1. Creating a separate Route Handler at /api/items/route.ts.
  2. Setting up a client-side fetch request with error state management.
  3. Manually invalidating frontend state or fetching refreshed lists.

In Next.js 15 with React 19, a Server Action is simply an async function marked with the 'use server' directive that executes securely on the server with direct database and ORM access.


Step 1: Type-Safe Server Action with Zod Validation

Create an action file app/actions/todos.ts:

'use server';

import { z } from 'zod';
import { revalidatePath } from 'next/cache';

const TodoSchema = z.object({
  title: z.string().min(3, 'Title must be at least 3 characters').max(80),
});

export type ActionState = {
  success: boolean;
  message?: string;
  errors?: Record<string, string[]>;
  data?: { id: string; title: string; completed: boolean };
};

// Simulated database
let database: Array<{ id: string; title: string; completed: boolean }> = [];

export async function createTodoAction(
  prevState: ActionState,
  formData: FormData
): Promise<ActionState> {
  const rawTitle = formData.get('title');

  const validated = TodoSchema.safeParse({ title: rawTitle });

  if (!validated.success) {
    return {
      success: false,
      errors: validated.error.flatten().fieldErrors,
    };
  }

  // Artificial network latency to demonstrate optimistic UI
  await new Promise((resolve) => setTimeout(resolve, 800));

  const newTodo = {
    id: crypto.randomUUID(),
    title: validated.data.title,
    completed: false,
  };

  database.push(newTodo);

  revalidatePath('/todos');

  return {
    success: true,
    data: newTodo,
  };
}

Step 2: Implementing Optimistic UI with React 19

The useOptimistic hook displays the expected result in the UI immediately while the network request executes in the background.

Create components/TodoList.tsx:

'use client';

import { useActionState, useOptimistic, useRef } from 'react';
import { createTodoAction, type ActionState } from '@/app/actions/todos';

interface Todo {
  id: string;
  title: string;
  completed: boolean;
  pending?: boolean;
}

interface Props {
  initialTodos: Todo[];
}

const initialState: ActionState = {
  success: false,
};

export default function TodoList({ initialTodos }: Props) {
  const formRef = useRef<HTMLFormElement>(null);

  const [state, formAction, isPending] = useActionState(
    async (prev: ActionState, formData: FormData) => {
      const title = formData.get('title') as string;
      if (title) {
        addOptimisticTodo(title);
      }
      formRef.current?.reset();
      return createTodoAction(prev, formData);
    },
    initialState
  );

  const [optimisticTodos, addOptimisticTodo] = useOptimistic(
    initialTodos,
    (current, newTitle: string) => [
      ...current,
      {
        id: `temp-${Date.now()}`,
        title: newTitle,
        completed: false,
        pending: true,
      },
    ]
  );

  return (
    <div className="max-w-md mx-auto p-6 bg-slate-900 text-white rounded-xl shadow-lg border border-slate-800">
      <h2 className="text-xl font-bold mb-4">Production Todo Manager</h2>

      <form ref={formRef} action={formAction} className="flex gap-2 mb-6">
        <input
          name="title"
          placeholder="New task title..."
          required
          disabled={isPending}
          className="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-sm text-white focus:outline-none focus:border-cyan-400"
        />
        <button
          type="submit"
          disabled={isPending}
          className="px-4 py-2 bg-cyan-500 hover:bg-cyan-400 text-slate-950 font-semibold rounded-lg text-sm transition-colors"
        >
          {isPending ? 'Adding...' : 'Add Task'}
        </button>
      </form>

      {state.errors?.title && (
        <p className="text-red-400 text-xs mb-3">{state.errors.title[0]}</p>
      )}

      <ul className="space-y-2">
        {optimisticTodos.map((todo) => (
          <li
            key={todo.id}
            className={`p-3 rounded-lg flex items-center justify-between text-sm ${
              todo.pending ? 'opacity-60 bg-slate-800/50' : 'bg-slate-800'
            }`}
          >
            <span>{todo.title}</span>
            {todo.pending && (
              <span className="text-[10px] text-cyan-300 animate-pulse font-mono">
                Saving...
              </span>
            )}
          </li>
        ))}
      </ul>
    </div>
  );
}

Best Practices for Next.js 15 Server Actions

  1. Always Validate Input on the Server: Never trust client-submitted data; validate using Zod or Valibot.
  2. Authorize User Sessions: Check await getServerSession(authOptions) inside every mutating action.
  3. Use Granular Revalidation: Call revalidatePath('/resource') or revalidateTag('tag') to keep static caches synchronized.

Companion Architectural Guides

Tags:#Next.js#Server Actions#React 19#useOptimistic#Zod#forms
Dev Pattern Labs Engineering Team

Written by Dev Pattern LabsCore Engineering

Dev Pattern Labs is an engineering lab and digital consultancy specializing in custom Next.js web applications, WooCommerce REST API integrations, and workflow automation for growing businesses.

Recommended Companion Articles