Skip to content

Server Actions

Handle form submissions and mutations with Next.js Server Actions.

What Are Server Actions?

Server Actions are async functions that run on the server. They can be called from Client and Server Components to handle form submissions and data mutations.

// This runs on the server
async function createPost(formData: FormData) {
  'use server';

  const title = formData.get('title') as string;
  const content = formData.get('content') as string;

  await db.post.create({ data: { title, content } });
  revalidatePath('/posts');
}

Basic Usage

Form Submission

// app/posts/new/page.tsx
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';

async function createPost(formData: FormData) {
  'use server';

  const title = formData.get('title') as string;
  const content = formData.get('content') as string;

  const post = await db.post.create({
    data: { title, content },
  });

  revalidatePath('/posts');
  redirect(`/posts/${post.id}`);
}

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

With Validation

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

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

const PostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(1).max(10000),
});

export type ActionState = {
  errors?: {
    title?: string[];
    content?: string[];
  };
  message?: string;
};

export async function createPost(
  prevState: ActionState,
  formData: FormData
): Promise<ActionState> {
  // Validate input
  const validatedFields = PostSchema.safeParse({
    title: formData.get('title'),
    content: formData.get('content'),
  });

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

  // Create post
  try {
    await db.post.create({
      data: validatedFields.data,
    });
  } catch (error) {
    return {
      message: 'Failed to create post',
    };
  }

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

Client Component with useFormState

'use client';

import { useFormState, useFormStatus } from 'react-dom';
import { createPost, type ActionState } from '@/app/actions/posts';

const initialState: ActionState = {};

function SubmitButton() {
  const { pending } = useFormStatus();

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

export function PostForm() {
  const [state, formAction] = useFormState(createPost, initialState);

  return (
    <form action={formAction}>
      <div>
        <label htmlFor="title">Title</label>
        <input id="title" name="title" />
        {state.errors?.title && (
          <p className="error">{state.errors.title.join(', ')}</p>
        )}
      </div>

      <div>
        <label htmlFor="content">Content</label>
        <textarea id="content" name="content" />
        {state.errors?.content && (
          <p className="error">{state.errors.content.join(', ')}</p>
        )}
      </div>

      {state.message && <p className="error">{state.message}</p>}

      <SubmitButton />
    </form>
  );
}

Optimistic Updates

'use client';

import { useOptimistic } from 'react';
import { likePost } from '@/app/actions/posts';

interface Post {
  id: string;
  likes: number;
  isLiked: boolean;
}

export function LikeButton({ post }: { post: Post }) {
  const [optimisticPost, addOptimisticLike] = useOptimistic(
    post,
    (state, newLikes: number) => ({
      ...state,
      likes: newLikes,
      isLiked: !state.isLiked,
    })
  );

  async function handleLike() {
    addOptimisticLike(optimisticPost.likes + (optimisticPost.isLiked ? -1 : 1));
    await likePost(post.id);
  }

  return (
    <form action={handleLike}>
      <button type="submit">
        {optimisticPost.isLiked ? '❤️' : '🤍'} {optimisticPost.likes}
      </button>
    </form>
  );
}

Non-Form Actions

Direct Invocation

'use server';

export async function deletePost(postId: string) {
  const session = await getSession();
  if (!session) throw new Error('Unauthorized');

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

import { deletePost } from '@/app/actions/posts';

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

  function handleDelete() {
    startTransition(async () => {
      await deletePost(postId);
    });
  }

  return (
    <button onClick={handleDelete} disabled={isPending}>
      {isPending ? 'Deleting...' : 'Delete'}
    </button>
  );
}

With Arguments

'use server';

export async function updatePostStatus(postId: string, status: string) {
  await db.post.update({
    where: { id: postId },
    data: { status },
  });
  revalidatePath('/posts');
}
'use client';

export function StatusSelect({ postId, currentStatus }: Props) {
  async function handleChange(e: React.ChangeEvent<HTMLSelectElement>) {
    await updatePostStatus(postId, e.target.value);
  }

  return (
    <select defaultValue={currentStatus} onChange={handleChange}>
      <option value="draft">Draft</option>
      <option value="published">Published</option>
    </select>
  );
}

Error Handling

Try-Catch Pattern

'use server';

import { revalidatePath } from 'next/cache';

export async function createPost(formData: FormData) {
  try {
    const post = await db.post.create({
      data: {
        title: formData.get('title') as string,
        content: formData.get('content') as string,
      },
    });

    revalidatePath('/posts');
    return { success: true, post };
  } catch (error) {
    if (error instanceof PrismaClientKnownRequestError) {
      if (error.code === 'P2002') {
        return { success: false, error: 'Title already exists' };
      }
    }
    return { success: false, error: 'Failed to create post' };
  }
}

Error Boundary

'use client';

import { useFormState } from 'react-dom';
import { createPost } from '@/app/actions/posts';

export function PostForm() {
  const [state, formAction] = useFormState(createPost, null);

  return (
    <form action={formAction}>
      {/* Form fields */}

      {state?.error && (
        <div className="error-banner">
          {state.error}
        </div>
      )}
    </form>
  );
}

Authentication

Protecting Actions

'use server';

import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';

export async function createPost(formData: FormData) {
  const session = await getServerSession(authOptions);

  if (!session) {
    throw new Error('You must be signed in');
  }

  const post = await db.post.create({
    data: {
      title: formData.get('title') as string,
      content: formData.get('content') as string,
      authorId: session.user.id,
    },
  });

  revalidatePath('/posts');
  return post;
}

Authorization

'use server';

export async function deletePost(postId: string) {
  const session = await getServerSession(authOptions);
  if (!session) throw new Error('Unauthorized');

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

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

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

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

File Uploads

'use server';

import { writeFile } from 'fs/promises';
import { join } from 'path';

export async function uploadFile(formData: FormData) {
  const file = formData.get('file') as File;

  if (!file) {
    return { error: 'No file provided' };
  }

  // Validate file type
  const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
  if (!allowedTypes.includes(file.type)) {
    return { error: 'Invalid file type' };
  }

  // Validate file size (5MB)
  if (file.size > 5 * 1024 * 1024) {
    return { error: 'File too large' };
  }

  const bytes = await file.arrayBuffer();
  const buffer = Buffer.from(bytes);

  const filename = `${Date.now()}-${file.name}`;
  const path = join(process.cwd(), 'public/uploads', filename);

  await writeFile(path, buffer);

  return { url: `/uploads/${filename}` };
}

Revalidation

'use server';

import { revalidatePath, revalidateTag } from 'next/cache';

export async function updatePost(postId: string, data: PostData) {
  await db.post.update({
    where: { id: postId },
    data,
  });

  // Revalidate specific path
  revalidatePath(`/posts/${postId}`);

  // Revalidate all posts
  revalidatePath('/posts');

  // Revalidate by tag
  revalidateTag('posts');
}

Best Practices

Practice Benefit
Validate all inputs Security, data integrity
Use Zod for schemas Type-safe validation
Handle errors gracefully Better UX
Check authentication Security
Use optimistic updates Better perceived performance
Revalidate affected paths Fresh data
Show loading states User feedback

When Not to Use

  • Complex multi-step wizards (consider client state)
  • Real-time features (use WebSocket/SSE)
  • File uploads > 4MB (use dedicated upload endpoint)
  • Actions that don't mutate data (use API routes)