Skip to main content

React 19 Server Actions and Form Handling: Complete Guide

Master React 19 Server Actions for form handling, data mutations, and server-side operations. Learn progressive enhancement, error handling, validation, and best practices with practical examples.

Table of Contents

Introduction

Form handling has long been one of the most complex aspects of building React applications. Traditional approaches require managing form state, handling validation, coordinating API calls, managing loading states, and handling errors—all while ensuring a smooth user experience. React 19’s Server Actions revolutionize this workflow by providing a seamless way to execute server-side code directly from client components, eliminating the need for API routes and simplifying form submissions.

Server Actions represent a paradigm shift in how we think about data mutations in React applications. Instead of creating separate API endpoints and managing fetch calls, you can define server-side functions that execute securely on the server and can be called directly from your React components. This approach reduces boilerplate, improves security by keeping sensitive logic on the server, and enables progressive enhancement—forms work even without JavaScript.

With React 19 and frameworks like Next.js 16+, Server Actions are production-ready and provide powerful features like automatic form validation, built-in error handling, and seamless integration with Server Components. Understanding Server Actions is essential for modern React development, especially when building full-stack applications.

This comprehensive guide will teach you everything you need to know about Server Actions in React 19. You’ll learn how to create and use Server Actions, handle forms effectively, implement progressive enhancement, manage errors gracefully, and follow best practices. By the end, you’ll be able to build robust, performant forms that leverage the full power of React 19’s server-side capabilities.


What Are Server Actions?

Server Actions are asynchronous server-side functions that can be called directly from React components. They execute on the server, have access to server-side resources like databases and file systems, and can be invoked from both Server Components and Client Components. Server Actions provide a type-safe, secure way to handle data mutations without creating separate API routes.

Key Characteristics

Server-Side Execution: Server Actions run exclusively on the server, never in the browser. This means sensitive operations like database queries, authentication checks, and file operations happen securely on the server.

Direct Function Calls: Unlike traditional API calls that require fetch requests and route handlers, Server Actions can be called like regular functions. React handles the serialization and network communication automatically.

Type Safety: When using TypeScript with Server Actions, you get end-to-end type safety from the server function to the client component, catching errors at compile time.

Progressive Enhancement: Forms using Server Actions work without JavaScript enabled, falling back to traditional form submissions while maintaining full functionality when JavaScript is available.

How Server Actions Work

When you call a Server Action from a component, React serializes the function call and sends it to the server over HTTP. The server executes the function, returns the result, and React updates the UI accordingly. This process is transparent to developers—you write code as if calling a regular function, but React handles all the networking complexity.

// Server Action (runs on server)
async function createUser(formData: FormData) {
'use server';
const name = formData.get('name') as string;
const email = formData.get('email') as string;
// Database operation happens on server
const user = await db.users.create({ name, email });
return { success: true, user };
}
// Client Component (runs in browser)
function UserForm() {
return (
<form action={createUser}>
<input name="name" required />
<input name="email" type="email" required />
<button type="submit">Create User</button>
</form>
);
}

Server Actions vs Traditional API Routes

AspectServer ActionsTraditional API Routes
SetupDefine function with 'use server'Create route handler file
CallingDirect function callfetch() or HTTP client
Type SafetyEnd-to-end TypeScriptManual type definitions
Form IntegrationNative <form action> supportManual form handling
Progressive EnhancementBuilt-inManual implementation
Error HandlingIntegrated with ReactManual error management
Code LocationCo-located with componentsSeparate API directory

Server Actions eliminate the need for separate API route files, reducing code duplication and improving maintainability. They’re particularly powerful when combined with Server Components, as explored in our guide on React Server Components vs Client Components.


Setting Up Server Actions

Prerequisites

To use Server Actions, you need:

  • React 19+ or Next.js 16+ (which includes React 19)
  • Node.js 18+ for server-side execution
  • TypeScript (recommended) for type safety

Framework Setup

Next.js 16+ (App Router)

Next.js has built-in support for Server Actions. No additional configuration is required:

app/actions/user.ts
"use server";
export async function createUser(formData: FormData) {
// Server Action code here
}

React 19 Standalone

For React 19 applications without Next.js, you’ll need to configure a server that supports Server Actions. This typically involves setting up a React Server Components runtime:

server.ts
import { renderToPipeableStream } from "react-dom/server";
import { createServer } from "http";
// Server setup for Server Actions
const server = createServer(async (req, res) => {
// Handle Server Action requests
if (req.method === "POST" && req.url === "/_actions") {
// Process Server Action
}
});

⚠️ Note: Server Actions work best with frameworks that provide server-side rendering. While possible to use with standalone React, frameworks like Next.js provide the best developer experience.

Creating Your First Server Action

Server Actions are defined using the 'use server' directive. This directive tells React that the function should execute on the server:

app/actions/todo.ts
"use server";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
export async function createTodo(formData: FormData) {
const title = formData.get("title") as string;
const description = formData.get("description") as string;
if (!title || title.trim().length === 0) {
return { error: "Title is required" };
}
const todo = await db.todos.create({
title: title.trim(),
description: description?.toString() || "",
completed: false,
});
// Revalidate the todos page to show new data
revalidatePath("/todos");
return { success: true, todo };
}
export async function deleteTodo(id: string) {
await db.todos.delete(id);
revalidatePath("/todos");
return { success: true };
}

File Organization Strategies

Recommended: Organize Server Actions by domain or feature:

app/
actions/
todos.ts # Todo-related actions
users.ts # User-related actions
comments.ts # Comment-related actions

Alternative: Co-locate with components when actions are component-specific:

app/
components/
UserForm.tsx
user-actions.ts # Actions specific to UserForm

Avoid: Mixing Server Actions with Client Components in the same file:

// ❌ Don't do this
"use client";
export function MyComponent() {
// Component code
}
("use server"); // This won't work!
export async function myAction() {
// Server Action code
}

Basic Server Actions Usage

Calling Server Actions from Forms

The simplest way to use Server Actions is with HTML forms. React automatically handles the submission:

app/actions/contact.ts
"use server";
export async function submitContact(formData: FormData) {
const name = formData.get("name") as string;
const email = formData.get("email") as string;
const message = formData.get("message") as string;
// Send email, save to database, etc.
await sendEmail({ name, email, message });
return { success: true };
}
app/components/ContactForm.tsx
import { submitContact } from '@/app/actions/contact';
export default function ContactForm() {
return (
<form action={submitContact}>
<div>
<label htmlFor="name">Name</label>
<input
id="name"
name="name"
type="text"
required
/>
</div>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
required
/>
</div>
<div>
<label htmlFor="message">Message</label>
<textarea
id="message"
name="message"
required
/>
</div>
<button type="submit">Send Message</button>
</form>
);
}

Calling Server Actions Programmatically

You can also call Server Actions programmatically from event handlers or other functions:

'use client';
import { useState } from 'react';
import { createTodo } from '@/app/actions/todo';
export function TodoForm() {
const [isPending, setIsPending] = useState(false);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setIsPending(true);
const formData = new FormData(event.currentTarget);
const result = await createTodo(formData);
if (result.error) {
alert(result.error);
} else {
event.currentTarget.reset();
// Show success message
}
setIsPending(false);
}
return (
<form onSubmit={handleSubmit}>
<input name="title" required />
<button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create Todo'}
</button>
</form>
);
}

Passing Data to Server Actions

Server Actions accept various data types:

FormData (most common for forms):

async function handleForm(formData: FormData) {
const value = formData.get("fieldName");
}

Primitive values:

async function updateStatus(id: string, status: "active" | "inactive") {
// Server Action code
}
// Call from component
await updateStatus("123", "active");

Objects (serialized automatically):

async function createUser(user: { name: string; email: string }) {
// Server Action code
}
await createUser({ name: "John", email: "john@example.com" });

⚠️ Important: Only serializable data can be passed to Server Actions. Functions, class instances, and non-serializable objects cannot be passed. Use FormData or plain objects instead.


Form Handling with Server Actions

Basic Form Submission

Server Actions integrate seamlessly with HTML forms, providing a declarative way to handle submissions:

app/actions/newsletter.ts
"use server";
export async function subscribeNewsletter(formData: FormData) {
const email = formData.get("email") as string;
// Validate email
if (!email || !email.includes("@")) {
return { error: "Invalid email address" };
}
// Add to newsletter
await addToNewsletter(email);
return { success: true, message: "Successfully subscribed!" };
}
app/components/NewsletterForm.tsx
import { subscribeNewsletter } from '@/app/actions/newsletter';
export default function NewsletterForm() {
return (
<form action={subscribeNewsletter}>
<input
name="email"
type="email"
placeholder="Enter your email"
required
/>
<button type="submit">Subscribe</button>
</form>
);
}

Using useFormStatus Hook

React 19 provides the useFormStatus hook to access form submission state within form components:

'use client';
import { useFormStatus } from 'react-dom';
import { submitForm } from '@/app/actions/form';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Submitting...' : 'Submit'}
</button>
);
}
export function MyForm() {
return (
<form action={submitForm}>
<input name="name" required />
<SubmitButton />
</form>
);
}

💡 Tip: useFormStatus must be used within a component that’s a descendant of a <form> element. It provides access to the nearest form’s submission state.

Using useActionState Hook

The useActionState hook (formerly useFormState) manages form state and handles Server Action results:

'use client';
import { useActionState } from 'react';
import { createPost } from '@/app/actions/posts';
function CreatePostForm() {
const [state, formAction, isPending] = useActionState(createPost, null);
return (
<form action={formAction}>
<input name="title" required />
<textarea name="content" required />
{state?.error && (
<div className="error">{state.error}</div>
)}
{state?.success && (
<div className="success">Post created successfully!</div>
)}
<button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create Post'}
</button>
</form>
);
}
app/actions/posts.ts
"use server";
export async function createPost(prevState: any, formData: FormData) {
const title = formData.get("title") as string;
const content = formData.get("content") as string;
if (!title || title.trim().length < 3) {
return { error: "Title must be at least 3 characters" };
}
if (!content || content.trim().length < 10) {
return { error: "Content must be at least 10 characters" };
}
const post = await db.posts.create({ title, content });
return { success: true, post };
}

Handling File Uploads

Server Actions support file uploads through FormData:

app/actions/upload.ts
"use server";
export async function uploadFile(formData: FormData) {
const file = formData.get("file") as File;
if (!file) {
return { error: "No file provided" };
}
// Check file size (e.g., max 5MB)
if (file.size > 5 * 1024 * 1024) {
return { error: "File size must be less than 5MB" };
}
// Save file to server
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
const filename = `${Date.now()}-${file.name}`;
const path = `./uploads/${filename}`;
await fs.writeFile(path, buffer);
return { success: true, filename };
}
app/components/FileUpload.tsx
'use client';
import { useActionState } from 'react';
import { uploadFile } from '@/app/actions/upload';
export function FileUploadForm() {
const [state, formAction, isPending] = useActionState(uploadFile, null);
return (
<form action={formAction} encType="multipart/form-data">
<input
name="file"
type="file"
accept="image/*"
required
/>
{state?.error && (
<div className="error">{state.error}</div>
)}
{state?.success && (
<div className="success">
File uploaded: {state.filename}
</div>
)}
<button type="submit" disabled={isPending}>
{isPending ? 'Uploading...' : 'Upload'}
</button>
</form>
);
}

Progressive Enhancement

One of the most powerful features of Server Actions is progressive enhancement—forms work without JavaScript, then enhance with JavaScript when available.

How Progressive Enhancement Works

When JavaScript is disabled, forms submit using traditional HTML form submission. When JavaScript is enabled, React intercepts the submission and handles it via Server Actions, providing a smoother experience with loading states and instant feedback.

// This form works with or without JavaScript
export default function ContactForm() {
return (
<form action={submitContact}>
<input name="name" required />
<input name="email" type="email" required />
<button type="submit">Submit</button>
</form>
);
}

Enhancing Forms with JavaScript

You can add JavaScript enhancements while maintaining the base functionality:

'use client';
import { useActionState } from 'react';
import { submitContact } from '@/app/actions/contact';
export function EnhancedContactForm() {
const [state, formAction, isPending] = useActionState(submitContact, null);
return (
<form action={formAction}>
<input name="name" required />
<input name="email" type="email" required />
{/* Only shown when JavaScript is enabled */}
{isPending && <div>Submitting...</div>}
{state?.error && <div className="error">{state.error}</div>}
{state?.success && <div className="success">Message sent!</div>}
<button type="submit" disabled={isPending}>
{isPending ? 'Sending...' : 'Submit'}
</button>
</form>
);
}

Testing Progressive Enhancement

Test without JavaScript:

  1. Disable JavaScript in your browser
  2. Submit the form
  3. Verify it works and shows appropriate feedback

Test with JavaScript:

  1. Enable JavaScript
  2. Submit the form
  3. Verify enhanced features (loading states, instant feedback) work

💡 Pro Tip: Progressive enhancement ensures your forms are accessible and work for all users, including those with slow connections or JavaScript disabled. This improves SEO and user experience.


Error Handling and Validation

Server-Side Validation

Always validate data on the server, even if you have client-side validation:

app/actions/user.ts
"use server";
import { z } from "zod";
const createUserSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Invalid email address"),
age: z.number().int().min(18, "Must be at least 18 years old"),
});
export async function createUser(prevState: any, formData: FormData) {
try {
// Parse and validate
const rawData = {
name: formData.get("name"),
email: formData.get("email"),
age: Number(formData.get("age")),
};
const validated = createUserSchema.parse(rawData);
// Create user
const user = await db.users.create(validated);
return { success: true, user };
} catch (error) {
if (error instanceof z.ZodError) {
return {
error: "Validation failed",
errors: error.errors.map((e) => ({
field: e.path[0],
message: e.message,
})),
};
}
return { error: "Failed to create user" };
}
}

Displaying Validation Errors

Show field-specific errors in your forms:

'use client';
import { useActionState } from 'react';
import { createUser } from '@/app/actions/user';
export function UserForm() {
const [state, formAction, isPending] = useActionState(createUser, null);
return (
<form action={formAction}>
<div>
<label htmlFor="name">Name</label>
<input
id="name"
name="name"
required
/>
{state?.errors?.find(e => e.field === 'name') && (
<span className="error">
{state.errors.find(e => e.field === 'name')?.message}
</span>
)}
</div>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
required
/>
{state?.errors?.find(e => e.field === 'email') && (
<span className="error">
{state.errors.find(e => e.field === 'email')?.message}
</span>
)}
</div>
<div>
<label htmlFor="age">Age</label>
<input
id="age"
name="age"
type="number"
required
/>
{state?.errors?.find(e => e.field === 'age') && (
<span className="error">
{state.errors.find(e => e.field === 'age')?.message}
</span>
)}
</div>
{state?.error && (
<div className="error">{state.error}</div>
)}
<button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create User'}
</button>
</form>
);
}

Handling Async Errors

Server Actions can throw errors that need to be caught:

app/actions/payment.ts
"use server";
export async function processPayment(formData: FormData) {
try {
const amount = Number(formData.get("amount"));
const cardNumber = formData.get("cardNumber") as string;
// Process payment
const result = await paymentGateway.charge(amount, cardNumber);
if (!result.success) {
return {
error: "Payment failed",
details: result.errorMessage,
};
}
return { success: true, transactionId: result.id };
} catch (error) {
// Log error for debugging
console.error("Payment error:", error);
// Return user-friendly error
return {
error: "An error occurred processing your payment",
};
}
}

Client-Side Validation (Optional Enhancement)

While server-side validation is required, client-side validation improves UX:

'use client';
import { useActionState } from 'react';
import { createUser } from '@/app/actions/user';
export function UserFormWithClientValidation() {
const [state, formAction, isPending] = useActionState(createUser, null);
const [clientErrors, setClientErrors] = useState<Record<string, string>>({});
function validateForm(formData: FormData) {
const errors: Record<string, string> = {};
const name = formData.get('name') as string;
if (!name || name.length < 2) {
errors.name = 'Name must be at least 2 characters';
}
const email = formData.get('email') as string;
if (!email || !email.includes('@')) {
errors.email = 'Invalid email address';
}
return errors;
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const errors = validateForm(formData);
if (Object.keys(errors).length > 0) {
setClientErrors(errors);
return;
}
setClientErrors({});
await formAction(formData);
}
return (
<form onSubmit={handleSubmit}>
{/* Form fields with client error display */}
</form>
);
}

⚠️ Critical: Never rely solely on client-side validation. Always validate on the server to ensure data integrity and security.


Optimistic Updates

Optimistic updates provide instant feedback by updating the UI before the server responds, then reverting if the operation fails.

Using useOptimistic Hook

React 19’s useOptimistic hook simplifies optimistic updates:

'use client';
import { useOptimistic, useActionState } from 'react';
import { updateTodo } from '@/app/actions/todos';
type Todo = {
id: string;
title: string;
completed: boolean;
};
export function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(state, newTodo: Todo) => [...state, newTodo]
);
async function handleToggle(todo: Todo) {
// Optimistically update UI
addOptimisticTodo({ ...todo, completed: !todo.completed });
// Update on server
await updateTodo(todo.id, { completed: !todo.completed });
}
return (
<ul>
{optimisticTodos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => handleToggle(todo)}
/>
{todo.title}
</li>
))}
</ul>
);
}

Optimistic Updates with Error Handling

Handle failures and revert optimistic updates:

'use client';
import { useOptimistic, useState } from 'react';
import { likePost } from '@/app/actions/posts';
export function Post({ post }: { post: Post }) {
const [optimisticLikes, addOptimisticLike] = useOptimistic(
post.likes,
(state, increment: number) => state + increment
);
const [error, setError] = useState<string | null>(null);
async function handleLike() {
setError(null);
// Optimistically increment likes
addOptimisticLike(1);
try {
const result = await likePost(post.id);
if (result.error) {
// Revert optimistic update
addOptimisticLike(-1);
setError(result.error);
}
} catch (error) {
// Revert optimistic update
addOptimisticLike(-1);
setError('Failed to like post');
}
}
return (
<div>
<button onClick={handleLike}>
Like ({optimisticLikes})
</button>
{error && <div className="error">{error}</div>}
</div>
);
}

Combining useOptimistic with useActionState

Use both hooks together for comprehensive state management:

'use client';
import { useOptimistic, useActionState } from 'react';
import { addComment } from '@/app/actions/comments';
export function CommentSection({ comments }: { comments: Comment[] }) {
const [state, formAction, isPending] = useActionState(addComment, null);
const [optimisticComments, addOptimisticComment] = useOptimistic(
comments,
(state, newComment: Comment) => [...state, newComment]
);
async function handleSubmit(formData: FormData) {
// Create optimistic comment
const optimisticComment: Comment = {
id: `temp-${Date.now()}`,
content: formData.get('content') as string,
author: 'You',
createdAt: new Date(),
};
addOptimisticComment(optimisticComment);
// Submit to server
const result = await formAction(formData);
if (result.error) {
// Remove optimistic comment on error
// (You'd need to track and remove it)
}
}
return (
<div>
<form action={handleSubmit}>
<textarea name="content" required />
<button type="submit" disabled={isPending}>
{isPending ? 'Posting...' : 'Post Comment'}
</button>
</form>
<div>
{optimisticComments.map(comment => (
<div key={comment.id}>
<strong>{comment.author}</strong>
<p>{comment.content}</p>
</div>
))}
</div>
</div>
);
}

💡 Best Practice: Use optimistic updates for operations that are likely to succeed and where instant feedback improves UX. Always handle failures gracefully and revert changes when needed.


Advanced Patterns

Server Actions with Authentication

Secure Server Actions by checking authentication:

app/actions/protected.ts
"use server";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
export async function updateProfile(formData: FormData) {
// Check authentication
const session = await auth();
if (!session) {
redirect("/login");
}
const name = formData.get("name") as string;
// Update user profile
await db.users.update(session.user.id, { name });
return { success: true };
}

Revalidating Data

Use revalidatePath and revalidateTag to refresh cached data:

app/actions/posts.ts
"use server";
import { revalidatePath, revalidateTag } from "next/cache";
export async function createPost(formData: FormData) {
const post = await db.posts.create({
title: formData.get("title") as string,
content: formData.get("content") as string,
});
// Revalidate specific path
revalidatePath("/posts");
// Or revalidate by tag
revalidateTag("posts");
return { success: true, post };
}

Streaming Responses

Server Actions can return streaming data for long-running operations:

app/actions/export.ts
"use server";
export async function exportData(formData: FormData) {
const format = formData.get("format") as string;
// Return a stream for large exports
return new ReadableStream({
async start(controller) {
const data = await generateExport(format);
for (const chunk of data) {
controller.enqueue(chunk);
}
controller.close();
},
});
}

Batch Operations

Process multiple items efficiently:

app/actions/todos.ts
"use server";
export async function deleteTodos(ids: string[]) {
// Use Promise.all for parallel execution
await Promise.all(ids.map((id) => db.todos.delete(id)));
revalidatePath("/todos");
return { success: true, deleted: ids.length };
}

Conditional Server Actions

Create flexible Server Actions that handle different scenarios:

app/actions/user.ts
"use server";
export async function updateUser(
userId: string,
formData: FormData,
options?: { notify?: boolean },
) {
const updates: Record<string, any> = {};
if (formData.has("name")) {
updates.name = formData.get("name");
}
if (formData.has("email")) {
updates.email = formData.get("email");
}
const user = await db.users.update(userId, updates);
if (options?.notify) {
await sendNotification(user.email, "Profile updated");
}
return { success: true, user };
}

Best Practices

✅ Do’s

1. Always validate on the server

"use server";
export async function createUser(formData: FormData) {
// ✅ Always validate server-side
const email = formData.get("email") as string;
if (!email || !email.includes("@")) {
return { error: "Invalid email" };
}
// Proceed with creation
}

2. Use TypeScript for type safety

"use server";
type CreateUserResult =
| { success: true; user: User }
| { success: false; error: string };
export async function createUser(
formData: FormData,
): Promise<CreateUserResult> {
// Type-safe implementation
}

3. Handle errors gracefully

"use server";
export async function riskyOperation(formData: FormData) {
try {
// Risky operation
return { success: true };
} catch (error) {
// ✅ Log for debugging
console.error("Operation failed:", error);
// ✅ Return user-friendly error
return {
error: "Operation failed. Please try again.",
};
}
}

4. Revalidate data after mutations

"use server";
import { revalidatePath } from "next/cache";
export async function updatePost(id: string, formData: FormData) {
await db.posts.update(id, {
title: formData.get("title") as string,
});
// ✅ Revalidate to show fresh data
revalidatePath("/posts");
revalidatePath(`/posts/${id}`);
return { success: true };
}

5. Use progressive enhancement

// ✅ Form works without JavaScript
export default function MyForm() {
return (
<form action={submitAction}>
{/* Form fields */}
<button type="submit">Submit</button>
</form>
);
}

❌ Don’ts

1. Don’t trust client-side validation alone

// ❌ Never do this
"use client";
export function BadForm() {
function handleSubmit(event: React.FormEvent) {
event.preventDefault();
const email = event.currentTarget.email.value;
// ❌ Client-side only validation
if (email.includes("@")) {
// Submit without server validation
}
}
}

2. Don’t expose sensitive logic

// ❌ Don't do this
"use client";
export function BadComponent() {
async function handleSubmit() {
// ❌ Sensitive logic in client component
const password = hashPassword(formData.get("password"));
await fetch("/api/users", {
method: "POST",
body: JSON.stringify({ password }),
});
}
}

3. Don’t forget error handling

// ❌ Don't do this
"use server";
export async function badAction(formData: FormData) {
// ❌ No error handling
const result = await riskyOperation(formData);
return result; // Might throw
}

4. Don’t mix Server Actions with Client Components incorrectly

// ❌ Don't do this
"use client";
export function BadComponent() {
// ❌ Can't define Server Action in Client Component
async function serverAction() {
"use server"; // This won't work!
}
}

5. Don’t ignore loading states

// ❌ Don't do this
export function BadForm() {
return (
<form action={slowAction}>
{/* ❌ No loading indicator */}
<button type="submit">Submit</button>
</form>
);
}

Common Pitfalls to Avoid

Pitfall 1: Forgetting ‘use server’ Directive

Wrong:

// Missing 'use server'
export async function myAction(formData: FormData) {
// This won't work as a Server Action
}

Correct:

"use server";
export async function myAction(formData: FormData) {
// Now it's a Server Action
}

Pitfall 2: Passing Non-Serializable Data

Wrong:

"use server";
export async function badAction(callback: Function) {
// ❌ Functions can't be serialized
}

Correct:

"use server";
export async function goodAction(data: { name: string; age: number }) {
// ✅ Plain objects are serializable
}

Pitfall 3: Not Handling Errors

Wrong:

"use server";
export async function riskyAction(formData: FormData) {
// ❌ No error handling
const result = await db.operation(formData);
return result;
}

Correct:

"use server";
export async function safeAction(formData: FormData) {
try {
const result = await db.operation(formData);
return { success: true, result };
} catch (error) {
return { success: false, error: "Operation failed" };
}
}

Pitfall 4: Not Revalidating After Mutations

Wrong:

"use server";
export async function createItem(formData: FormData) {
await db.items.create(formData);
// ❌ UI won't update with new data
return { success: true };
}

Correct:

"use server";
import { revalidatePath } from "next/cache";
export async function createItem(formData: FormData) {
await db.items.create(formData);
revalidatePath("/items"); // ✅ UI updates
return { success: true };
}

Pitfall 5: Incorrect useFormStatus Usage

Wrong:

function MyComponent() {
const { pending } = useFormStatus(); // ❌ Not inside form
return <button>Submit</button>;
}

Correct:

function SubmitButton() {
const { pending } = useFormStatus(); // ✅ Inside form
return <button disabled={pending}>Submit</button>;
}
function MyForm() {
return (
<form action={myAction}>
<SubmitButton />
</form>
);
}

Conclusion

React 19 Server Actions represent a fundamental shift in how we handle data mutations and form submissions in React applications. By enabling direct server-side function calls from components, Server Actions eliminate the need for separate API routes, reduce boilerplate code, and provide built-in progressive enhancement.

Throughout this guide, we’ve explored the core concepts of Server Actions, from basic form handling to advanced patterns like optimistic updates and error handling. We’ve seen how Server Actions integrate seamlessly with React 19’s new hooks like useActionState and useOptimistic, providing powerful tools for building modern, performant applications.

Key takeaways:

  • Server Actions simplify form handling by eliminating the need for API routes and manual fetch calls
  • Progressive enhancement ensures your forms work for all users, with or without JavaScript
  • Type safety is built-in when using TypeScript, catching errors at compile time
  • Error handling and validation are essential for robust applications
  • Optimistic updates provide instant feedback and improve user experience

As you build with Server Actions, remember to always validate on the server, handle errors gracefully, and revalidate data after mutations. Follow the best practices outlined in this guide, and avoid common pitfalls like forgetting the 'use server' directive or passing non-serializable data.

Server Actions work beautifully with React 19’s other features, including Server Components (as covered in our React Server Components guide) and the new hooks introduced in React 19 (explored in our React 19 features guide). Together, these features provide a powerful foundation for building modern, full-stack React applications.

Start implementing Server Actions in your projects, experiment with the patterns we’ve covered, and leverage the power of React 19’s server-side capabilities to build better, faster, and more maintainable applications.