React 19 Server Actions in Production
React 19 server actions fundamentally change how we handle forms and data mutations in React applications. Instead of writing API routes, fetch calls, loading states, and error handling separately, server actions let you define server-side functions that can be called directly from your components.
This guide covers the practical patterns for using server actions in production — from basic form handling to optimistic updates, progressive enhancement, and error boundaries. By the end, you will know when server actions simplify your code and when traditional API routes are still the better choice.
Understanding Server Actions
A server action is an async function marked with the “use server” directive. It runs exclusively on the server but can be called from client components as if it were a local function. React handles the network request, serialization, and state management automatically.
// app/actions/posts.ts
"use server";
import { db } from "@/lib/database";
import { revalidatePath } from "next/cache";
import { z } from "zod";
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
category: z.enum(["tech", "design", "business"]),
});
export async function createPost(prevState: ActionState, formData: FormData) {
// Validate input
const parsed = CreatePostSchema.safeParse({
title: formData.get("title"),
content: formData.get("content"),
category: formData.get("category"),
});
if (!parsed.success) {
return {
success: false,
errors: parsed.error.flatten().fieldErrors,
};
}
// Server-side logic — database, auth, etc.
try {
const post = await db.posts.create({
data: {
...parsed.data,
authorId: await getCurrentUserId(),
publishedAt: new Date(),
},
});
revalidatePath("/posts");
return { success: true, postId: post.id };
} catch (error) {
return { success: false, errors: { _form: ["Failed to create post"] } };
}
}
useActionState: The New Standard
React 19 introduces useActionState (replacing the experimental useFormState) as the primary hook for working with server actions. It manages pending state, return values, and progressive enhancement:
// app/components/CreatePostForm.tsx
"use client";
import { useActionState, useOptimistic } from "react";
import { createPost } from "@/app/actions/posts";
export function CreatePostForm() {
const [state, formAction, isPending] = useActionState(createPost, {
success: false,
errors: {},
});
return (
<form action={formAction}>
<div>
<label htmlFor="title">Title</label>
<input
id="title"
name="title"
required
disabled={isPending}
aria-describedby={state.errors?.title ? "title-error" : undefined}
/>
{state.errors?.title && (
<p id="title-error" className="text-red-500">
{state.errors.title[0]}
</p>
)}
</div>
<div>
<label htmlFor="content">Content</label>
<textarea id="content" name="content" required disabled={isPending} />
{state.errors?.content && (
<p className="text-red-500">{state.errors.content[0]}</p>
)}
</div>
<select name="category" required>
<option value="tech">Tech</option>
<option value="design">Design</option>
<option value="business">Business</option>
</select>
<button type="submit" disabled={isPending}>
{isPending ? "Creating..." : "Create Post"}
</button>
{state.errors?._form && (
<p className="text-red-500">{state.errors._form[0]}</p>
)}
</form>
);
}
Optimistic Updates with useOptimistic
For the best user experience, show the result immediately while the server action runs in the background. React 19’s useOptimistic hook makes this straightforward:
// Optimistic todo list
"use client";
import { useOptimistic } from "react";
import { toggleTodo } from "@/app/actions/todos";
export function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, setOptimisticTodo] = useOptimistic(
todos,
(currentTodos, toggledId: string) =>
currentTodos.map((todo) =>
todo.id === toggledId
? { ...todo, completed: !todo.completed }
: todo
)
);
return (
<ul>
{optimisticTodos.map((todo) => (
<li key={todo.id}>
<form
action={async () => {
setOptimisticTodo(todo.id); // Instant UI update
await toggleTodo(todo.id); // Server action in background
}}
>
<button type="submit">
{todo.completed ? "✓" : "○"} {todo.title}
</button>
</form>
</li>
))}
</ul>
);
}
Progressive Enhancement
One of the most powerful features of React 19 server actions is progressive enhancement. Forms using server actions work even before JavaScript loads. The form submits as a standard HTML form, the server action processes it, and the page re-renders with the result.
// This form works WITHOUT JavaScript loaded
// useActionState provides the progressive enhancement
export function SearchForm() {
const [state, action, isPending] = useActionState(searchPosts, {
results: [],
query: "",
});
return (
<form action={action}>
<input
name="query"
defaultValue={state.query}
placeholder="Search posts..."
/>
<button type="submit">Search</button>
{state.results.map((post) => (
<article key={post.id}>
<h3>{post.title}</h3>
<p>{post.excerpt}</p>
</article>
))}
</form>
);
}
When NOT to Use Server Actions
Server actions are not a replacement for all API routes. Additionally, avoid them for: real-time data (WebSockets/SSE are better), file uploads over 50MB (use presigned URLs), third-party webhook endpoints, or when you need fine-grained HTTP control (custom headers, status codes, streaming responses).
Key Takeaways
React 19 server actions eliminate the boilerplate of form handling — no manual fetch calls, no loading state management, no separate API routes for simple mutations. Combined with useActionState and useOptimistic, they provide a complete solution for data mutations. As a result, start using them for forms and simple mutations, but keep API routes for complex scenarios.
Related Reading
- React 19 Features & Migration Guide
- React Server Actions Full-Stack Patterns
- Next.js 15 App Router & Server Components