Next.js Server Actions

24. May, 2025 14 min read Develop

A Deep Dive into Server-Side Mutations

Server Actions have fundamentally changed how we handle data mutations in Next.js applications. Instead of creating separate API routes, you can now define server-side functions directly alongside your components, making full-stack development more intuitive than ever.

In this post, we’ll take a comprehensive look at Server Actions in Next.js, exploring practical patterns, real-world use cases, and best practices that will help you build robust full-stack applications with confidence.

What Are Server Actions?

Server Actions are asynchronous functions that execute on the server. They can be called from both Server and Client Components, enabling seamless data mutations without the need for traditional API endpoints. Introduced in Next.js 13.4 and stabilized in Next.js 14, they have become a cornerstone of the App Router architecture.

Before Server Actions, handling mutations in Next.js typically required creating API routes under pages/api/ or app/api/. This meant maintaining separate endpoint files, manually managing request and response objects, and handling serialization yourself. Server Actions eliminate this overhead entirely by letting you write server-side logic as plain TypeScript functions.

At their core, Server Actions are defined using the 'use server' directive:

'use server';

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;

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

This function runs exclusively on the server, even when invoked from a client-side component. Next.js handles the serialization and network communication behind the scenes. When you call a Server Action from the client, Next.js automatically creates a POST request to the server, executes the function, and returns the result — all without you needing to manage the HTTP layer.

It’s worth noting that Server Actions are tightly integrated with React’s transition model. When called from forms or wrapped in startTransition, they participate in React’s concurrent rendering pipeline, which enables features like optimistic updates and automatic pending state tracking.

Server Actions vs API Routes

To understand why Server Actions are such a significant improvement, let’s compare the traditional API route approach with Server Actions:

1. The API Route Approach

// app/api/posts/route.ts
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';

export async function POST(request: Request) {
  const body = await request.json();
  const { title, content } = body;

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

  return NextResponse.json(post);
}
// app/posts/new/page.tsx
'use client';

export default function NewPostPage() {
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    const formData = new FormData(e.target as HTMLFormElement);

    await fetch('/api/posts', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        title: formData.get('title'),
        content: formData.get('content'),
      }),
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="title" type="text" required />
      <textarea name="content" required />
      <button type="submit">Create Post</button>
    </form>
  );
}

2. The Server Action Approach

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

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

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;

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

  revalidatePath('/posts');
}
// app/posts/new/page.tsx
import { createPost } from './actions';

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

The differences are substantial. With Server Actions, there’s no need for manual fetch calls, no JSON serialization, no managing request headers, and the page component can remain a Server Component. The form even works without JavaScript through progressive enhancement, which is not possible with the API route approach.

Server Actions with Forms

The most natural use case for Server Actions is form handling. You can pass a Server Action directly to a form’s action prop:

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

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

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

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

This approach works without JavaScript enabled, providing progressive enhancement out of the box. The form submits directly to the server, and the Server Action processes the data. When JavaScript is available, React intercepts the submission and handles it client-side for a smoother experience with no full-page reload.

Defining Server Actions

There are two ways to define Server Actions:

1. Module-Level Directive

Create a dedicated file with 'use server' at the top. All exported functions become Server Actions:

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

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

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;

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

  revalidatePath('/posts');
}

export async function deletePost(id: string) {
  await db.post.delete({ where: { id } });

  revalidatePath('/posts');
}

2. Inline Directive

Define a Server Action directly inside a Server Component:

// app/posts/page.tsx
export default function PostsPage() {
  async function handleDelete(formData: FormData) {
    'use server';
    const id = formData.get('id') as string;
    await db.post.delete({ where: { id } });
    revalidatePath('/posts');
  }

  return (
    <form action={handleDelete}>
      <input type="hidden" name="id" value="123" />
      <button type="submit">Delete</button>
    </form>
  );
}

The module-level approach is generally preferred for better code organization and reusability across components. Inline Server Actions are handy for quick, one-off operations that don’t need to be shared, but they can make components harder to read if overused.

Handling Pending States with useFormStatus

When a Server Action is processing, you want to provide visual feedback to the user. The useFormStatus hook from react-dom gives you access to the form’s pending state:

// components/SubmitButton.tsx
'use client';

import { useFormStatus } from 'react-dom';

export function SubmitButton({ label = 'Save' }: { label?: string }) {
  const { pending } = useFormStatus();

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

Use this component inside your form:

import { createPost } from './actions';
import { SubmitButton } from '@/components/SubmitButton';

export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" type="text" required />
      <textarea name="content" required />
      <SubmitButton label="Create Post" />
    </form>
  );
}

Note that useFormStatus must be used inside a component that is a child of the <form> element, not in the same component that renders the form. This is because the hook reads the status from the nearest parent form context. If you try to use it in the same component that contains the <form>, it won’t have access to the form’s state and pending will always be false.

Managing State with useActionState

For more complex scenarios where you need to track the result of a Server Action, useActionState provides a structured way to manage action state. This hook is particularly useful when you need to display success or error messages after the action completes:

// app/contact/page.tsx
'use client';

import { useActionState } from 'react';
import { sendMessage } from './actions';

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

export default function ContactPage() {
  const [state, formAction, isPending] = useActionState(
    sendMessage,
    initialState
  );

  return (
    <form action={formAction}>
      <input name="email" type="email" required />
      <textarea name="message" required />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Sending...' : 'Send Message'}
      </button>
      {state.message && (
        <p className={state.success ? 'text-green' : 'text-red'}>
          {state.message}
        </p>
      )}
    </form>
  );
}

The corresponding Server Action returns the state object. Notice that when using useActionState, the action receives the previous state as its first argument:

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

export async function sendMessage(prevState: any, formData: FormData) {
  const email = formData.get('email') as string;
  const message = formData.get('message') as string;

  try {
    await sendEmail({ to: email, body: message });

    return { success: true, message: 'Message sent successfully!' };
  } catch (error) {
    return { success: false, message: 'Failed to send message.' };
  }
}

The useActionState hook returns three values: the current state, a wrapped action function to pass to the form, and an isPending boolean. The state updates automatically when the Server Action returns, making it straightforward to build reactive form UIs.

Optimistic Updates with useOptimistic

For a snappier user experience, you can use useOptimistic to update the UI immediately while the Server Action processes in the background. This is especially useful for interactions like liking a post, toggling a bookmark, or adding an item to a list where the user expects instant feedback:

'use client';

import { useOptimistic, useTransition } from 'react';
import { toggleLike } from './actions';

type Post = {
  id: string;
  title: string;
  liked: boolean;
};

export function PostItem({ post }: { post: Post }) {
  const [isPending, startTransition] = useTransition();
  const [optimisticPost, setOptimisticPost] = useOptimistic(
    post,
    (currentPost, newLiked: boolean) => ({
      ...currentPost,
      liked: newLiked,
    })
  );

  const handleToggle = () => {
    startTransition(async () => {
      setOptimisticPost(!optimisticPost.liked);
      await toggleLike(post.id);
    });
  };

  return (
    <div>
      <h3>{optimisticPost.title}</h3>
      <button onClick={handleToggle} disabled={isPending}>
        {optimisticPost.liked ? 'Unlike' : 'Like'}
      </button>
    </div>
  );
}

When calling a Server Action outside of a form’s action prop, you need to wrap it in startTransition to properly tie the optimistic update to the server round-trip. Without startTransition, the optimistic state may revert prematurely. If the Server Action fails, React automatically reverts the optimistic state to the previous value, ensuring data consistency.

Revalidation and Redirects

After mutating data, you typically want to refresh the page or redirect the user. Next.js provides two key utilities for this:

Revalidating Data

Use revalidatePath or revalidateTag to invalidate cached data after a mutation. The difference is scope — revalidatePath invalidates all data associated with a specific URL path, while revalidateTag invalidates all fetch requests tagged with a specific cache tag:

'use server';

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

export async function updatePost(formData: FormData) {
  const id = formData.get('id') as string;
  const title = formData.get('title') as string;

  await db.post.update({
    where: { id },
    data: { title },
  });

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

  // Or revalidate by cache tag
  revalidateTag('posts');
}

Using revalidateTag is generally more precise, as it only invalidates the specific data that changed rather than everything on a page. To use it, tag your fetch calls with next: { tags: ['posts'] } when fetching data.

Redirecting After Mutation

Use redirect to navigate the user after a successful mutation:

'use server';

import { redirect } from 'next/navigation';

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string;

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

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

Note that redirect must be called outside of a try/catch block, as it works by throwing a special NEXT_REDIRECT error internally. If you catch this error, the redirect won’t happen. If you need both error handling and redirecting, store the redirect path in a variable and call redirect after the try/catch.

Validation with Zod

Server Actions should always validate incoming data. Never trust data from the client, even if you have client-side validation in place — it can be bypassed. Zod is a popular choice for schema validation that pairs well with TypeScript:

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

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

const PostSchema = z.object({
  title: z.string().min(1, 'Title is required').max(200),
  content: z.string().min(10, 'Content must be at least 10 characters'),
  category: z.enum(['tutorial', 'guide', 'opinion']),
});

export async function createPost(prevState: any, formData: FormData) {
  const rawData = {
    title: formData.get('title'),
    content: formData.get('content'),
    category: formData.get('category'),
  };

  const validated = PostSchema.safeParse(rawData);

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

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

  revalidatePath('/posts');

  return { success: true, errors: null };
}

This pattern returns field-level errors that you can display next to each form input, providing clear feedback to the user. The flatten() method on the Zod error object transforms the errors into a simple object with field names as keys and arrays of error messages as values, making it easy to map errors to specific form fields.

Error Handling

Proper error handling in Server Actions is crucial for a good user experience. You can use try/catch blocks and return structured error responses:

'use server';

import { revalidatePath } from 'next/cache';

export async function processPayment(formData: FormData) {
  const amount = Number(formData.get('amount'));

  try {
    const result = await paymentService.charge(amount);

    revalidatePath('/billing');

    return { success: true, transactionId: result.id };
  } catch (error) {
    if (error instanceof PaymentError) {
      return { success: false, error: error.message };
    }

    return { success: false, error: 'An unexpected error occurred.' };
  }
}

For unhandled errors, Next.js will display the nearest error.tsx boundary, giving you a fallback UI for unexpected failures. It’s important to be careful about what error information you expose to the client. Avoid sending raw error messages from your database or internal services, as they might contain sensitive information. Instead, return user-friendly messages and log the detailed errors server-side.

Calling Server Actions Outside Forms

Server Actions are not limited to forms. You can call them from event handlers, useEffect, or any other client-side code:

'use client';

import { incrementViewCount } from './actions';
import { useEffect, useTransition } from 'react';

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

  useEffect(() => {
    startTransition(() => {
      incrementViewCount(postId);
    });
  }, [postId]);

  return null;
}
// actions.ts
'use server';

export async function incrementViewCount(postId: string) {
  await db.post.update({
    where: { id: postId },
    data: { views: { increment: 1 } },
  });
}

This flexibility makes Server Actions a versatile tool for any server-side operation, not just form submissions. However, keep in mind that each Server Action call triggers a network request, so be mindful of performance when calling them frequently or in loops.

Security Considerations

Server Actions are exposed as public HTTP endpoints under the hood, which means they should be treated with the same security considerations as API routes:

  • Authentication: Always verify that the user is authenticated before performing sensitive operations. Check the session or token inside your Server Action.
  • Authorization: Verify that the authenticated user has permission to perform the requested action. Don’t assume that because a user can see a button, they’re allowed to click it.
  • Input Sanitization: Even with Zod validation, sanitize inputs to prevent injection attacks, especially if the data ends up in SQL queries or HTML output.
  • Rate Limiting: Consider implementing rate limiting for Server Actions that could be abused, such as contact forms or payment operations.
'use server';

import { auth } from '@/lib/auth';

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 } });

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

  await db.post.delete({ where: { id } });

  revalidatePath('/posts');
}

Best Practices

Here are some guidelines to follow when working with Server Actions:

  • Always Validate Input: Never trust data from the client. Use Zod or similar libraries to validate all inputs on the server.
  • Keep Actions Focused: Each Server Action should do one thing well. Avoid combining multiple unrelated operations in a single action.
  • Handle Errors Gracefully: Return structured error responses instead of throwing errors that might expose implementation details.
  • Use Revalidation Strategically: Only revalidate the paths or tags that are affected by the mutation to avoid unnecessary re-renders.
  • Separate Action Files: Keep your Server Actions in dedicated files (actions.ts) for better organization and testability.
  • Protect Sensitive Actions: Always check authentication and authorization within your Server Actions before performing mutations.
  • Avoid Heavy Computation: Server Actions should be fast. Offload heavy processing to background jobs or queues.
  • Use TypeScript: Type your Server Action inputs and return values for better developer experience and fewer runtime errors.

Conclusion

Next.js Server Actions represent a significant shift in how we build full-stack React applications. By eliminating the boilerplate of API routes and providing first-class support for forms, pending states, and optimistic updates, they enable a more streamlined development experience.

Combined with React 19’s hooks like useActionState, useFormStatus, and useOptimistic, Server Actions provide a complete toolkit for handling data mutations in modern web applications. Whether you’re building a simple contact form or a complex dashboard, Server Actions help you write cleaner, more maintainable code while keeping your application secure and performant.

‘Till next time!