Server Actions & Mutations

Handle form submissions and data mutations with Server Actions

What are Server Actions?

Server Actions are asynchronous functions that execute on the server. They can be used in Server and Client Components to handle form submissions and data mutations. No need to create API routes for basic CRUD operations!

⚡ Server Actions Benefits

  • ✅ Progressive enhancement - works without JavaScript
  • ✅ Type-safe with TypeScript
  • ✅ Integrated with Next.js caching
  • ✅ No API boilerplate needed
  • ✅ Automatic form validation

Basic Server Action

// app/actions.ts
'use server'; // Mark the entire file as server actions

import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { db } from '@/lib/db';

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;
  
  // Validate
  if (!title || title.length < 3) {
    return { error: 'Title must be at least 3 characters' };
  }
  
  // Create in database
  const post = await db.post.create({
    data: { title, content },
  });
  
  // Revalidate and redirect
  revalidatePath('/posts');
  redirect(`/posts/${post.id}`);
}

// Using in a Server Component
// app/posts/new/page.tsx
import { createPost } from '@/app/actions';

export default function NewPost() {
  return (
    <form action={createPost}>
      <input name="title" placeholder="Title" required />
      <textarea name="content" placeholder="Content" />
      <button type="submit">Create Post</button>
    </form>
  );
}

useActionState for Form State

'use client';
import { useActionState } from 'react';
import { createUser } from '@/app/actions';

// Action with state
// app/actions.ts
'use server';
export async function createUser(
  prevState: { error?: string; success?: boolean },
  formData: FormData
) {
  const email = formData.get('email') as string;
  
  if (!email.includes('@')) {
    return { error: 'Invalid email address' };
  }
  
  try {
    await db.user.create({ data: { email } });
    return { success: true };
  } catch (e) {
    return { error: 'Email already exists' };
  }
}

// Client Component with form state
export default function SignupForm() {
  const [state, formAction, isPending] = useActionState(createUser, {});
  
  return (
    <form action={formAction}>
      <input 
        name="email" 
        type="email" 
        placeholder="Email"
        disabled={isPending}
      />
      
      {state.error && (
        <p className="text-red-500">{state.error}</p>
      )}
      
      {state.success && (
        <p className="text-green-500">User created!</p>
      )}
      
      <button type="submit" disabled={isPending}>
        {isPending ? 'Creating...' : 'Sign Up'}
      </button>
    </form>
  );
}

useFormStatus for Pending State

'use client';
import { useFormStatus } from 'react-dom';

// Submit button component
function SubmitButton() {
  const { pending } = useFormStatus();
  
  return (
    <button 
      type="submit" 
      disabled={pending}
      className={pending ? 'opacity-50' : ''}
    >
      {pending ? 'Submitting...' : 'Submit'}
    </button>
  );
}

// Form component
export default function ContactForm() {
  return (
    <form action={sendMessage}>
      <input name="message" placeholder="Message" />
      <SubmitButton /> {/* Must be inside the form */}
    </form>
  );
}

Optimistic Updates

'use client';
import { useOptimistic } from 'react';
import { addTodo } from '@/app/actions';

interface Todo {
  id: string;
  text: string;
  pending?: boolean;
}

export default function TodoList({ todos }: { todos: Todo[] }) {
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(
    todos,
    (state: Todo[], newTodo: string) => [
      ...state,
      { id: crypto.randomUUID(), text: newTodo, pending: true },
    ]
  );

  async function formAction(formData: FormData) {
    const text = formData.get('text') as string;
    addOptimisticTodo(text); // Update UI immediately
    await addTodo(text);     // Then persist to server
  }

  return (
    <div>
      <form action={formAction}>
        <input name="text" placeholder="Add todo..." />
        <button type="submit">Add</button>
      </form>
      
      <ul>
        {optimisticTodos.map(todo => (
          <li 
            key={todo.id}
            className={todo.pending ? 'opacity-50' : ''}
          >
            {todo.text}
          </li>
        ))}
      </ul>
    </div>
  );
}

Validation with Zod

// app/actions.ts
'use server';
import { z } from 'zod';

const PostSchema = z.object({
  title: z.string().min(3).max(100),
  content: z.string().min(10),
  category: z.enum(['tech', 'lifestyle', 'news']),
});

export async function createPost(
  prevState: { errors?: z.ZodError['errors'] },
  formData: FormData
) {
  const rawData = {
    title: formData.get('title'),
    content: formData.get('content'),
    category: formData.get('category'),
  };

  const result = PostSchema.safeParse(rawData);

  if (!result.success) {
    return { errors: result.error.errors };
  }

  // Data is validated and typed!
  const { title, content, category } = result.data;
  
  await db.post.create({
    data: { title, content, category },
  });

  revalidatePath('/posts');
  redirect('/posts');
}

Calling Actions Programmatically

'use client';
import { deletePost, likePost } from '@/app/actions';
import { useTransition } from 'react';

export function PostActions({ postId }: { postId: string }) {
  const [isPending, startTransition] = useTransition();

  const handleDelete = () => {
    if (!confirm('Delete this post?')) return;
    
    startTransition(async () => {
      await deletePost(postId);
    });
  };

  const handleLike = () => {
    startTransition(async () => {
      await likePost(postId);
    });
  };

  return (
    <div>
      <button 
        onClick={handleLike}
        disabled={isPending}
      >
        ❤️ Like
      </button>
      <button 
        onClick={handleDelete}
        disabled={isPending}
        className="text-red-500"
      >
        🗑️ Delete
      </button>
    </div>
  );
}

📖 Server Actions Documentation →

✅ Server Actions Best Practices

  • • Always validate input on the server
  • • Use useActionState for form feedback
  • • Use useFormStatus for loading states
  • • Use useOptimistic for instant UI updates
  • • Revalidate cache after mutations
  • • Handle errors gracefully