TechLead

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

Server Actions run on the server, so you can validate input, write to the database, and revalidate or redirect after a mutation.

// 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

useActionState lets a Client Component receive the action result, which is useful for showing validation errors or success messages.

'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

useFormStatus exposes the pending state for the nearest form, enabling disabled buttons and loading feedback during submission.

'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

Optimistic UI shows updates immediately while the server action runs, then reconciles with the actual result.

'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

Validate and coerce input on the server to ensure consistent data before writing to the database.

// 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 useTransition to trigger actions from event handlers without blocking UI updates.

'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