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.
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 Case | Server Actions | API Routes |
|---|---|---|
| Form submissions | ✅ Ideal | Works but verbose |
| CRUD operations | ✅ Great | Overhead |
| 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.