Back to Blog

Next.js Data Mutation: Complete Server Actions Guide

Introduction: The End of API Routes for Mutations

Server Actions represent a paradigm shift in how we handle data mutations in React applications. Instead of creating API routes, writing fetch calls, and managing client-side state, you write async functions that run on the server and call them directly from your components.

The result: type-safe, progressively enhanced forms with automatic revalidation—all without the boilerplate of traditional REST endpoints.

Server Actions Architecture

┌─────────────────────────────────────────────────────────────────────┐
│                    SERVER ACTIONS FLOW                               │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  CLIENT                              SERVER                         │
│  ──────                              ──────                         │
│                                                                     │
│  ┌─────────────────┐                                                │
│  │  <form>         │                                                │
│  │    action={fn}  │ ──── POST Request ────►  ┌──────────────────┐ │
│  │  </form>        │      (FormData)          │  Server Action   │ │
│  └─────────────────┘                          │  ──────────────  │ │
│         │                                     │  'use server'    │ │
│         │                                     │                  │ │
│         │                                     │  1. Auth check   │ │
│         │                                     │  2. Validate     │ │
│         │                                     │  3. Mutate DB    │ │
│         │                                     │  4. Revalidate   │ │
│         │                                     │  5. Redirect?    │ │
│         │                                     └────────┬─────────┘ │
│         │                                              │            │
│         │                          ◄── Response ───────┘            │
│         │                          (Updated UI + Data)              │
│         ▼                                                           │
│  ┌─────────────────┐                                                │
│  │  Updated UI     │  ◄── Single roundtrip, no client state mgmt   │
│  │  (revalidated)  │                                                │
│  └─────────────────┘                                                │
│                                                                     │
│  KEY BENEFITS:                                                      │
│  • Progressive enhancement (works without JS)                       │
│  • Type-safe end-to-end                                             │
│  • Automatic request deduplication                                  │
│  • Built-in pending states                                          │
│  • Integrated cache revalidation                                    │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

Creating Server Functions

The 'use server' Directive

Mark functions or files as server-only:

// Option 1: File-level directive (recommended for action files)
// app/actions.ts
'use server';

// ALL exports are Server Actions
export async function createPost(formData: FormData) {
  // Runs on server
}

export async function deletePost(id: string) {
  // Runs on server
}

export async function updatePost(id: string, formData: FormData) {
  // Runs on server
}
// Option 2: Function-level directive (for inline actions)
// app/posts/page.tsx

export default function PostsPage() {
  async function createPost(formData: FormData) {
    'use server';
    // This specific function runs on server
  }

  return <form action={createPost}>{/* ... */}</form>;
}

Complete Server Action Structure

// app/actions/posts.ts
'use server';

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

// Validation schema
const CreatePostSchema = z.object({
  title: z.string().min(1, 'Title is required').max(100),
  content: z.string().min(10, 'Content must be at least 10 characters'),
  published: z.boolean().default(false),
});

// Return type for form state
type ActionState = {
  success: boolean;
  message: string;
  errors?: {
    title?: string[];
    content?: string[];
  };
};

export async function createPost(
  prevState: ActionState | null,
  formData: FormData
): Promise<ActionState> {
  // 1. Authentication
  const session = await auth();
  if (!session?.user) {
    return {
      success: false,
      message: 'You must be logged in to create a post',
    };
  }

  // 2. Validation
  const validatedFields = CreatePostSchema.safeParse({
    title: formData.get('title'),
    content: formData.get('content'),
    published: formData.get('published') === 'true',
  });

  if (!validatedFields.success) {
    return {
      success: false,
      message: 'Invalid fields',
      errors: validatedFields.error.flatten().fieldErrors,
    };
  }

  // 3. Mutation
  try {
    const post = await db.post.create({
      data: {
        ...validatedFields.data,
        authorId: session.user.id,
      },
    });

    // 4. Revalidation
    revalidatePath('/posts');
    revalidatePath(`/users/${session.user.id}`);

    // 5. Success response (or redirect)
    return {
      success: true,
      message: 'Post created successfully',
    };
  } catch (error) {
    return {
      success: false,
      message: 'Database error: Failed to create post',
    };
  }
}

export async function deletePost(id: string): Promise<ActionState> {
  const session = await auth();
  if (!session?.user) {
    return { success: false, message: 'Unauthorized' };
  }

  // Verify ownership
  const post = await db.post.findUnique({
    where: { id },
    select: { authorId: true },
  });

  if (!post) {
    return { success: false, message: 'Post not found' };
  }

  if (post.authorId !== session.user.id) {
    return { success: false, message: 'You can only delete your own posts' };
  }

  try {
    await db.post.delete({ where: { id } });
    revalidatePath('/posts');
    return { success: true, message: 'Post deleted' };
  } catch (error) {
    return { success: false, message: 'Failed to delete post' };
  }
}

Invoking Server Actions

// app/posts/new/page.tsx
import { createPost } from '@/app/actions/posts';

export default function NewPostPage() {
  return (
    <form action={createPost}>
      <div>
        <label htmlFor="title">Title</label>
        <input
          id="title"
          name="title"
          type="text"
          required
        />
      </div>

      <div>
        <label htmlFor="content">Content</label>
        <textarea
          id="content"
          name="content"
          required
        />
      </div>

      <div>
        <label>
          <input type="checkbox" name="published" value="true" />
          Publish immediately
        </label>
      </div>

      <button type="submit">Create Post</button>
    </form>
  );
}

Method 2: Form with useActionState (With Loading/Error States)

// app/posts/new/form.tsx
'use client';

import { useActionState } from 'react';
import { createPost } from '@/app/actions/posts';

const initialState = {
  success: false,
  message: '',
  errors: undefined,
};

export function CreatePostForm() {
  const [state, formAction, isPending] = useActionState(
    createPost,
    initialState
  );

  return (
    <form action={formAction}>
      {/* Global error message */}
      {state.message && !state.success && (
        <div className="error-banner" role="alert">
          {state.message}
        </div>
      )}

      {/* Success message */}
      {state.success && (
        <div className="success-banner" role="status">
          {state.message}
        </div>
      )}

      <div>
        <label htmlFor="title">Title</label>
        <input
          id="title"
          name="title"
          type="text"
          aria-describedby="title-error"
          disabled={isPending}
        />
        {state.errors?.title && (
          <p id="title-error" className="error">
            {state.errors.title[0]}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="content">Content</label>
        <textarea
          id="content"
          name="content"
          aria-describedby="content-error"
          disabled={isPending}
        />
        {state.errors?.content && (
          <p id="content-error" className="error">
            {state.errors.content[0]}
          </p>
        )}
      </div>

      <button type="submit" disabled={isPending}>
        {isPending ? 'Creating...' : 'Create Post'}
      </button>
    </form>
  );
}

Method 3: Button with formAction

// Multiple actions in one form
'use client';

import { saveAsDraft, publish } from '@/app/actions/posts';

export function PostForm() {
  return (
    <form>
      <input name="title" type="text" />
      <textarea name="content" />

      {/* Different buttons trigger different actions */}
      <button type="submit" formAction={saveAsDraft}>
        Save as Draft
      </button>
      <button type="submit" formAction={publish}>
        Publish
      </button>
    </form>
  );
}

Method 4: Event Handler (onClick)

// app/posts/[id]/like-button.tsx
'use client';

import { useState, useTransition } from 'react';
import { likePost } from '@/app/actions/posts';

interface LikeButtonProps {
  postId: string;
  initialLikes: number;
  initialLiked: boolean;
}

export function LikeButton({
  postId,
  initialLikes,
  initialLiked,
}: LikeButtonProps) {
  const [likes, setLikes] = useState(initialLikes);
  const [liked, setLiked] = useState(initialLiked);
  const [isPending, startTransition] = useTransition();

  async function handleClick() {
    startTransition(async () => {
      const result = await likePost(postId);
      if (result.success) {
        setLikes(result.likes);
        setLiked(result.liked);
      }
    });
  }

  return (
    <button
      onClick={handleClick}
      disabled={isPending}
      className={liked ? 'liked' : ''}
    >
      {isPending ? '...' : liked ? '❤️' : '🤍'} {likes}
    </button>
  );
}

Method 5: useEffect (Auto-trigger)

// app/posts/[id]/view-counter.tsx
'use client';

import { useEffect, useTransition } from 'react';
import { incrementViews } from '@/app/actions/analytics';

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

  useEffect(() => {
    // Increment view count on mount
    startTransition(async () => {
      await incrementViews(postId);
    });
  }, [postId]);

  return null; // Or show view count
}

Optimistic Updates with useOptimistic

Show immediate feedback while the server processes:

// app/posts/[id]/comments.tsx
'use client';

import { useOptimistic, useTransition } from 'react';
import { addComment } from '@/app/actions/comments';

interface Comment {
  id: string;
  text: string;
  author: string;
  createdAt: Date;
  pending?: boolean;
}

interface CommentsProps {
  postId: string;
  initialComments: Comment[];
}

export function Comments({ postId, initialComments }: CommentsProps) {
  const [isPending, startTransition] = useTransition();

  const [optimisticComments, addOptimisticComment] = useOptimistic(
    initialComments,
    (state: Comment[], newComment: Comment) => [...state, newComment]
  );

  async function handleSubmit(formData: FormData) {
    const text = formData.get('text') as string;

    // Optimistic update - show immediately
    const optimisticComment: Comment = {
      id: `temp-${Date.now()}`,
      text,
      author: 'You',
      createdAt: new Date(),
      pending: true,
    };

    startTransition(async () => {
      addOptimisticComment(optimisticComment);
      await addComment(postId, formData);
    });
  }

  return (
    <div>
      <ul>
        {optimisticComments.map((comment) => (
          <li
            key={comment.id}
            className={comment.pending ? 'opacity-50' : ''}
          >
            <p>{comment.text}</p>
            <small>
              {comment.author} • {comment.pending ? 'Posting...' : formatDate(comment.createdAt)}
            </small>
          </li>
        ))}
      </ul>

      <form action={handleSubmit}>
        <input name="text" placeholder="Add a comment..." />
        <button type="submit" disabled={isPending}>
          Post
        </button>
      </form>
    </div>
  );
}

Advanced Optimistic Pattern: Todo List

// app/todos/todo-list.tsx
'use client';

import { useOptimistic, useTransition, useRef } from 'react';
import { addTodo, toggleTodo, deleteTodo } from '@/app/actions/todos';

interface Todo {
  id: string;
  text: string;
  completed: boolean;
  pending?: 'adding' | 'toggling' | 'deleting';
}

type OptimisticAction =
  | { type: 'add'; todo: Todo }
  | { type: 'toggle'; id: string }
  | { type: 'delete'; id: string };

export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
  const [isPending, startTransition] = useTransition();
  const formRef = useRef<HTMLFormElement>(null);

  const [optimisticTodos, dispatch] = useOptimistic(
    initialTodos,
    (state: Todo[], action: OptimisticAction) => {
      switch (action.type) {
        case 'add':
          return [...state, action.todo];
        case 'toggle':
          return state.map((todo) =>
            todo.id === action.id
              ? { ...todo, completed: !todo.completed, pending: 'toggling' }
              : todo
          );
        case 'delete':
          return state.map((todo) =>
            todo.id === action.id
              ? { ...todo, pending: 'deleting' }
              : todo
          );
        default:
          return state;
      }
    }
  );

  async function handleAdd(formData: FormData) {
    const text = formData.get('text') as string;
    if (!text.trim()) return;

    const tempId = `temp-${Date.now()}`;

    startTransition(async () => {
      dispatch({
        type: 'add',
        todo: { id: tempId, text, completed: false, pending: 'adding' },
      });
      formRef.current?.reset();
      await addTodo(formData);
    });
  }

  async function handleToggle(id: string) {
    startTransition(async () => {
      dispatch({ type: 'toggle', id });
      await toggleTodo(id);
    });
  }

  async function handleDelete(id: string) {
    startTransition(async () => {
      dispatch({ type: 'delete', id });
      await deleteTodo(id);
    });
  }

  return (
    <div>
      <form ref={formRef} action={handleAdd}>
        <input name="text" placeholder="Add a todo..." />
        <button type="submit">Add</button>
      </form>

      <ul>
        {optimisticTodos
          .filter((todo) => todo.pending !== 'deleting')
          .map((todo) => (
            <li
              key={todo.id}
              className={todo.pending ? 'opacity-50' : ''}
            >
              <input
                type="checkbox"
                checked={todo.completed}
                onChange={() => handleToggle(todo.id)}
                disabled={!!todo.pending}
              />
              <span className={todo.completed ? 'line-through' : ''}>
                {todo.text}
              </span>
              <button
                onClick={() => handleDelete(todo.id)}
                disabled={!!todo.pending}
              >
                Delete
              </button>
            </li>
          ))}
      </ul>
    </div>
  );
}

Cache Revalidation

revalidatePath - Revalidate by Route

'use server';

import { revalidatePath } from 'next/cache';

export async function createPost(formData: FormData) {
  // ... create post

  // Revalidate specific path
  revalidatePath('/posts');

  // Revalidate with specific type
  revalidatePath('/posts', 'page');   // Only the page
  revalidatePath('/posts', 'layout'); // Layout and all pages below

  // Revalidate dynamic route
  revalidatePath(`/posts/${postId}`);

  // Revalidate all paths (use sparingly)
  revalidatePath('/', 'layout');
}

revalidateTag - Revalidate by Tag

// lib/data.ts
export async function getPosts() {
  const response = await fetch('https://api.example.com/posts', {
    next: { tags: ['posts'] }, // Tag the cache
  });
  return response.json();
}

export async function getPost(id: string) {
  const response = await fetch(`https://api.example.com/posts/${id}`, {
    next: { tags: ['posts', `post-${id}`] }, // Multiple tags
  });
  return response.json();
}

// app/actions/posts.ts
'use server';

import { revalidateTag } from 'next/cache';

export async function createPost(formData: FormData) {
  // ... create post

  // Revalidate all caches tagged with 'posts'
  revalidateTag('posts');
}

export async function updatePost(id: string, formData: FormData) {
  // ... update post

  // Revalidate specific post and list
  revalidateTag(`post-${id}`);
  revalidateTag('posts');
}

refresh() - Refresh Current Route

'use server';

import { refresh } from 'next/cache';

export async function updateUserPreferences(formData: FormData) {
  // ... update preferences

  // Refresh current page (re-runs Server Components)
  refresh();
}

Redirecting After Mutation

'use server';

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

export async function createPost(formData: FormData) {
  const session = await auth();
  if (!session) {
    redirect('/login'); // Redirect unauthenticated users
  }

  // ... create post

  revalidatePath('/posts');

  // IMPORTANT: redirect() throws, code after won't execute
  redirect(`/posts/${post.id}`);

  // This never runs!
  console.log('after redirect');
}

// Conditional redirect
export async function submitForm(formData: FormData) {
  const result = await processForm(formData);

  if (result.needsVerification) {
    redirect('/verify-email');
  }

  if (result.isNewUser) {
    redirect('/onboarding');
  }

  revalidatePath('/dashboard');
  redirect('/dashboard');
}
'use server';

import { cookies } from 'next/headers';

export async function setTheme(theme: 'light' | 'dark') {
  const cookieStore = await cookies();

  cookieStore.set('theme', theme, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 365, // 1 year
    path: '/',
  });

  // UI automatically re-renders with new cookie value
}

export async function logout() {
  const cookieStore = await cookies();

  // Delete session cookie
  cookieStore.delete('session');

  // Or set with past expiry
  cookieStore.set('session', '', {
    expires: new Date(0),
  });
}

export async function getPreferences() {
  const cookieStore = await cookies();

  const theme = cookieStore.get('theme')?.value ?? 'light';
  const locale = cookieStore.get('locale')?.value ?? 'en';

  return { theme, locale };
}

Error Handling Patterns

'use server';

type ActionResult<T = void> =
  | { success: true; data: T }
  | { success: false; error: string };

export async function createPost(
  formData: FormData
): Promise<ActionResult<{ id: string }>> {
  try {
    const session = await auth();
    if (!session) {
      return { success: false, error: 'Unauthorized' };
    }

    const validated = schema.safeParse(Object.fromEntries(formData));
    if (!validated.success) {
      return { success: false, error: 'Validation failed' };
    }

    const post = await db.post.create({ data: validated.data });

    revalidatePath('/posts');
    return { success: true, data: { id: post.id } };
  } catch (error) {
    console.error('Create post error:', error);
    return { success: false, error: 'Something went wrong' };
  }
}

Throwing Errors (For Error Boundaries)

'use server';

export async function deletePost(id: string) {
  const session = await auth();
  if (!session) {
    throw new Error('Unauthorized'); // Caught by error.tsx
  }

  const post = await db.post.findUnique({ where: { id } });
  if (!post) {
    throw new Error('Post not found');
  }

  if (post.authorId !== session.user.id) {
    throw new Error('Forbidden');
  }

  await db.post.delete({ where: { id } });
  revalidatePath('/posts');
}

Custom Error Classes

// lib/errors.ts
export class ActionError extends Error {
  constructor(
    message: string,
    public code: string,
    public status: number = 400
  ) {
    super(message);
    this.name = 'ActionError';
  }
}

export class UnauthorizedError extends ActionError {
  constructor(message = 'Unauthorized') {
    super(message, 'UNAUTHORIZED', 401);
  }
}

export class ForbiddenError extends ActionError {
  constructor(message = 'Forbidden') {
    super(message, 'FORBIDDEN', 403);
  }
}

export class NotFoundError extends ActionError {
  constructor(message = 'Not found') {
    super(message, 'NOT_FOUND', 404);
  }
}

// app/actions/posts.ts
'use server';

import { UnauthorizedError, ForbiddenError, NotFoundError } from '@/lib/errors';

export async function deletePost(id: string) {
  const session = await auth();
  if (!session) {
    throw new UnauthorizedError();
  }

  const post = await db.post.findUnique({ where: { id } });
  if (!post) {
    throw new NotFoundError('Post not found');
  }

  if (post.authorId !== session.user.id) {
    throw new ForbiddenError('You can only delete your own posts');
  }

  await db.post.delete({ where: { id } });
  revalidatePath('/posts');
}

Security Best Practices

Always Authenticate and Authorize

'use server';

// ❌ BAD: No auth check
export async function deletePost(id: string) {
  await db.post.delete({ where: { id } });
}

// ✅ GOOD: Auth + ownership verification
export async function deletePost(id: string) {
  const session = await auth();
  if (!session?.user) {
    throw new Error('Unauthorized');
  }

  const post = await db.post.findUnique({
    where: { id },
    select: { authorId: true },
  });

  if (!post) {
    throw new Error('Post not found');
  }

  // Verify ownership OR admin role
  if (post.authorId !== session.user.id && session.user.role !== 'admin') {
    throw new Error('Forbidden');
  }

  await db.post.delete({ where: { id } });
  revalidatePath('/posts');
}

Validate All Inputs

'use server';

import { z } from 'zod';

const CreatePostSchema = z.object({
  title: z.string().min(1).max(200).trim(),
  content: z.string().min(1).max(50000),
  categoryId: z.string().uuid(),
});

export async function createPost(formData: FormData) {
  // Never trust client input
  const rawInput = {
    title: formData.get('title'),
    content: formData.get('content'),
    categoryId: formData.get('categoryId'),
  };

  const validated = CreatePostSchema.safeParse(rawInput);

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

  // Use validated.data, not rawInput
  await db.post.create({ data: validated.data });
}

Rate Limiting

'use server';

import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
import { headers } from 'next/headers';

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, '1 m'), // 10 requests per minute
});

export async function createComment(formData: FormData) {
  const session = await auth();
  if (!session) {
    throw new Error('Unauthorized');
  }

  // Rate limit by user ID or IP
  const identifier = session.user.id;
  const { success, limit, reset, remaining } = await ratelimit.limit(identifier);

  if (!success) {
    return {
      success: false,
      error: `Rate limited. Try again in ${Math.ceil((reset - Date.now()) / 1000)} seconds`,
    };
  }

  // Proceed with action...
}

Closure Security

// ❌ DANGEROUS: Sensitive data in closure
export default async function Page() {
  const user = await getUser();

  async function updateProfile(formData: FormData) {
    'use server';
    // user.secretToken is captured in closure and could be exposed
    await db.user.update({
      where: { id: user.id },
      data: { token: user.secretToken }, // DON'T DO THIS
    });
  }

  return <form action={updateProfile}>...</form>;
}

// ✅ SAFE: Re-fetch in action
export default async function Page() {
  const user = await getUser();

  async function updateProfile(formData: FormData) {
    'use server';
    // Re-authenticate inside the action
    const session = await auth();
    if (!session) throw new Error('Unauthorized');

    const freshUser = await getUser(session.user.id);
    // Use fresh data
  }

  return <form action={updateProfile}>...</form>;
}

Progressive Enhancement

Server Actions work without JavaScript:

// This form works even if JS fails to load or is disabled
export default function SignupForm() {
  return (
    <form action={signup}>
      <input name="email" type="email" required />
      <input name="password" type="password" required />
      <button type="submit">Sign Up</button>
    </form>
  );
}

// The action handles everything server-side
'use server';

export async function signup(formData: FormData) {
  const email = formData.get('email') as string;
  const password = formData.get('password') as string;

  // Validate
  // Create user
  // Set session cookie

  redirect('/dashboard');
}

Behavior by JS state:

JavaScript StatusForm Behavior
Not loaded yetForm queued, submitted after hydration
DisabledFull page reload, server handles response
LoadedClient-side transition, no full reload

Passing Additional Arguments

Using .bind()

// app/posts/[id]/page.tsx
import { deletePost } from '@/app/actions/posts';

export default async function PostPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const post = await getPost(id);

  // Bind the post ID to the action
  const deletePostWithId = deletePost.bind(null, id);

  return (
    <div>
      <h1>{post.title}</h1>
      <form action={deletePostWithId}>
        <button type="submit">Delete</button>
      </form>
    </div>
  );
}

// app/actions/posts.ts
'use server';

export async function deletePost(id: string, formData: FormData) {
  // id is bound, formData is from form submission
  await db.post.delete({ where: { id } });
  revalidatePath('/posts');
}

Using Hidden Inputs

// Alternative: hidden form fields
export default function PostActions({ postId }: { postId: string }) {
  return (
    <form action={deletePost}>
      <input type="hidden" name="postId" value={postId} />
      <button type="submit">Delete</button>
    </form>
  );
}

// Action extracts from formData
'use server';

export async function deletePost(formData: FormData) {
  const postId = formData.get('postId') as string;
  // ...
}

Key Takeaways

  1. Server Actions replace API routes for mutations: No more /api/posts endpoints—call functions directly from components.

  2. Always authenticate and authorize: Server Actions are public POST endpoints. Verify the user can perform the action.

  3. Validate all inputs: Use Zod or similar. Never trust FormData directly.

  4. useActionState for form state: Get pending status and return values from actions.

  5. useOptimistic for instant feedback: Update UI immediately, sync with server in background.

  6. Revalidate after mutations: Use revalidatePath or revalidateTag to refresh cached data.

  7. redirect() throws: Place it after revalidatePath. Code after redirect won't execute.

  8. Progressive enhancement built-in: Forms work without JavaScript—no extra code needed.

  9. Return errors, don't always throw: Return structured errors for form validation; throw for error boundaries.

  10. Bind arguments with .bind(): Pass additional data to actions beyond FormData.

What did you think?

Related Posts

© 2026 Vidhya Sagar Thakur. All rights reserved.