ReactFeatured

React 19 Server Actions: The Complete Guide to Full-Stack React Development

Master React 19 Server Actions to build full-stack applications with seamless server-client communication, form handling, and optimistic updates without separate API endpoints.

Sameer Sabir
Updated:
12 min read
ReactServer ActionsFull-StackNext.jsTypeScriptForms

React 19 Server Actions: The Complete Guide to Full-Stack React Development

React 19 has fundamentally changed how we think about the boundary between client and server code. Server Actions, now stable in React 19, allow you to define server-side functions that can be called directly from client components. This eliminates the need for manually creating API routes for many common patterns.

What Are Server Actions?

Server Actions are asynchronous functions that execute on the server but can be invoked from the client. They're defined using the "use server" directive and can be used in forms, event handlers, and effects.

// actions/user.ts
"use server";

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

export async function createUser(formData: FormData) {
  const name = formData.get("name") as string;
  const email = formData.get("email") as string;

  await db.user.create({
    data: { name, email },
  });

  revalidatePath("/users");
}

Using Server Actions in Forms

The most natural use case for Server Actions is form handling. React 19 extends the <form> element to accept Server Actions directly:

// components/CreateUserForm.tsx
import { createUser } from "@/actions/user";

export function CreateUserForm() {
  return (
    <form action={createUser}>
      <input name="name" placeholder="Name" required />
      <input name="email" type="email" placeholder="Email" required />
      <button type="submit">Create User</button>
    </form>
  );
}

Progressive Enhancement

One of the biggest advantages of Server Actions in forms is progressive enhancement. Forms work even before JavaScript loads on the client, making your application more resilient and accessible.

Optimistic Updates with useOptimistic

React 19 introduces useOptimistic to provide instant UI feedback while Server Actions process:

"use client";

import { useOptimistic } from "react";
import { addTodo } from "@/actions/todos";

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

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

  async function handleSubmit(formData: FormData) {
    const text = formData.get("text") as string;
    addOptimisticTodo(text);
    await addTodo(text);
  }

  return (
    <div>
      <form action={handleSubmit}>
        <input name="text" placeholder="Add todo..." />
        <button type="submit">Add</button>
      </form>
      <ul>
        {optimisticTodos.map((todo) => (
          <li key={todo.id} style={{ opacity: todo.pending ? 0.5 : 1 }}>
            {todo.text}
          </li>
        ))}
      </ul>
    </div>
  );
}

useActionState for Form State Management

The new useActionState hook provides a structured way to manage form submissions with loading states and error handling:

"use client";

import { useActionState } from "react";
import { submitContactForm } from "@/actions/contact";

type FormState = {
  message: string;
  success: boolean;
} | null;

export function ContactForm() {
  const [state, formAction, isPending] = useActionState<FormState, FormData>(
    submitContactForm,
    null
  );

  return (
    <form action={formAction}>
      <input name="email" type="email" required disabled={isPending} />
      <textarea name="message" required disabled={isPending} />
      
      <button type="submit" disabled={isPending}>
        {isPending ? "Sending..." : "Send Message"}
      </button>

      {state?.message && (
        <p className={state.success ? "text-green-500" : "text-red-500"}>
          {state.message}
        </p>
      )}
    </form>
  );
}

Security Best Practices

Input Validation

Always validate inputs on the server side, even when using Server Actions:

"use server";

import { z } from "zod";

const CreatePostSchema = z.object({
  title: z.string().min(5).max(200),
  content: z.string().min(50),
  category: z.enum(["tech", "design", "business"]),
});

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

  const validated = CreatePostSchema.safeParse(rawData);
  
  if (!validated.success) {
    return { error: validated.error.flatten().fieldErrors };
  }

  // Proceed with validated data
  await db.post.create({ data: validated.data });
  revalidatePath("/posts");
  return { success: true };
}

Authentication & Authorization

"use server";

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

export async function deletePost(postId: string) {
  const session = await auth();
  
  if (!session?.user) {
    throw new Error("Unauthorized");
  }

  const post = await db.post.findUnique({ where: { id: postId } });
  
  if (post?.authorId !== session.user.id) {
    throw new Error("Forbidden");
  }

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

Error Handling Patterns

Using Error Boundaries

Server Actions that throw errors can be caught by error boundaries:

// app/posts/error.tsx
"use client";

export default function Error({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  return (
    <div className="error-container">
      <h2>Something went wrong</h2>
      <p>{error.message}</p>
      <button onClick={reset}>Try again</button>
    </div>
  );
}

Returning Structured Errors

For more granular error handling, return error objects instead of throwing:

"use server";

type ActionResult = {
  success: boolean;
  message: string;
  errors?: Record<string, string[]>;
};

export async function updateProfile(formData: FormData): Promise<ActionResult> {
  try {
    // Validate and update
    return { success: true, message: "Profile updated successfully" };
  } catch (error) {
    return { 
      success: false, 
      message: "Failed to update profile",
      errors: { general: ["An unexpected error occurred"] }
    };
  }
}

Performance Considerations

Avoiding Waterfalls

Don't call multiple Server Actions sequentially when they can run in parallel:

// Bad: Sequential calls
const user = await getUser(id);
const posts = await getUserPosts(id);

// Good: Parallel calls
const [user, posts] = await Promise.all([
  getUser(id),
  getUserPosts(id),
]);

Caching and Revalidation

Use revalidatePath and revalidateTag strategically:

"use server";

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

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

  // Revalidate specific pages
  revalidatePath("/blogs");
  revalidatePath(`/blog/${postId}`);
  
  // Or revalidate by tag for more granular control
  revalidateTag("posts");
}

When to Use Server Actions vs API Routes

Use CaseServer ActionsAPI Routes
Form submissions✅ IdealWorks but verbose
CRUD operations✅ GreatOverhead
Third-party webhooks❌ Not suitable✅ Required
Public APIs❌ Not suitable✅ Required
File uploads✅ With FormData✅ Also works
Real-time data❌ Request-response✅ With WebSockets

Conclusion

React 19 Server Actions represent a paradigm shift in how we build full-stack React applications. They simplify the developer experience by eliminating boilerplate API routes, provide progressive enhancement out of the box, and integrate seamlessly with React's new hooks like useOptimistic and useActionState.

Start migrating your form handlers and simple CRUD operations to Server Actions today, and you'll immediately see improvements in code organization, type safety, and developer productivity.

Found this blog helpful? Have questions or suggestions?

Related Blogs