Introduction
React 19 has introduced one of the most significant paradigm shifts in React's history with Server Actions. This feature fundamentally transforms how we approach client-server interactions, data mutations, and form handling in modern React applications.
What Are Server Actions?
Server Actions are asynchronous functions that execute on the server and can be invoked directly from React components. They eliminate the traditional overhead of setting up separate API routes, managing fetch calls, and manually handling loading states.
Key Benefits
Getting Started
Basic Server Action
'use server';
import { revalidatePath } from 'next/cache';
import { db } from '@/lib/database';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
await db.posts.create({
data: { title, content }
});
revalidatePath('/posts');
return { success: true };
}
Client Component Usage
'use client';
import { createPost } from './actions';
export function CreatePostForm() {
return (
<form action={createPost}>
<input name="title" placeholder="Post title" />
<textarea name="content" placeholder="Post content" />
<button type="submit">Create Post</button>
</form>
);
}
Advanced Patterns
Optimistic Updates
Provide instant feedback while the server processes the request:
'use client';
import { useOptimistic } from 'react';
import { addComment } from './actions';
export function CommentSection({ comments }) {
const [optimisticComments, addOptimisticComment] = useOptimistic(
comments,
(state, newComment) => [...state, { ...newComment, pending: true }]
);
async function handleSubmit(formData: FormData) {
const comment = formData.get('comment') as string;
addOptimisticComment({ id: crypto.randomUUID(), comment });
await addComment(formData);
}
return (
<div>
{optimisticComments.map(comment => (
<div key={comment.id} className={comment.pending ? 'opacity-50' : ''}>
{comment.comment}
</div>
))}
<form action={handleSubmit}>
<input name="comment" />
<button>Add Comment</button>
</form>
</div>
);
}
Error Handling
Implement robust error handling with proper type safety:
'use server';
import { z } from 'zod';
const PostSchema = z.object({
title: z.string().min(3).max(100),
content: z.string().min(10).max(5000),
});
type ActionResult =
| { success: true; data: Post }
| { success: false; error: string; fieldErrors?: Record<string, string[]> };
export async function createPost(formData: FormData): Promise<ActionResult> {
try {
const rawData = {
title: formData.get('title'),
content: formData.get('content'),
};
const validated = PostSchema.parse(rawData);
const post = await db.posts.create({
data: validated
});
revalidatePath('/posts');
return { success: true, data: post };
} catch (error) {
if (error instanceof z.ZodError) {
return {
success: false,
error: 'Validation failed',
fieldErrors: error.flatten().fieldErrors
};
}
return {
success: false,
error: 'Failed to create post'
};
}
}
Production Best Practices
1. Authentication & Authorization
Always verify user permissions before executing sensitive operations:
'use server';
import { auth } from '@/lib/auth';
import { ForbiddenError, UnauthorizedError } from '@/lib/errors';
export async function deletePost(postId: string) {
const session = await auth();
if (!session?.user) {
throw new UnauthorizedError('You must be logged in');
}
const post = await db.posts.findUnique({
where: { id: postId }
});
if (post.authorId !== session.user.id && session.user.role !== 'admin') {
throw new ForbiddenError('You can only delete your own posts');
}
await db.posts.delete({
where: { id: postId }
});
revalidatePath('/posts');
}
2. Rate Limiting
Protect your API from abuse:
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '1 m'),
});
export async function createPost(formData: FormData) {
const identifier = await getClientIdentifier();
const { success, limit, remaining } = await ratelimit.limit(identifier);
if (!success) {
throw new Error(`Rate limit exceeded. Try again in ${Math.ceil((limit - Date.now()) / 1000)}s`);
}
// Proceed with post creation
}
3. Input Validation & Sanitization
Never trust user input:
import DOMPurify from 'isomorphic-dompurify';
import { z } from 'zod';
const SafePostSchema = z.object({
title: z.string()
.trim()
.min(3)
.max(100)
.regex(/^[a-zA-Z0-9\s\-:,.!?]+$/, 'Invalid characters in title'),
content: z.string()
.min(10)
.max(10000)
.transform(val => DOMPurify.sanitize(val, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'a', 'ul', 'ol', 'li'],
ALLOWED_ATTR: ['href', 'target', 'rel']
}))
});
Performance Optimization
Caching Strategies
Use Next.js caching utilities effectively:
import { unstable_cache } from 'next/cache';
const getCachedPosts = unstable_cache(
async (category?: string) => {
return await db.posts.findMany({
where: category ? { category } : undefined,
include: { author: true },
orderBy: { createdAt: 'desc' }
});
},
['posts'],
{
revalidate: 3600, // 1 hour
tags: ['posts']
}
);
export async function createPost(formData: FormData) {
// ... create post
// Invalidate cache
revalidateTag('posts');
}
Selective Revalidation
Only revalidate what changed:
export async function updatePost(postId: string, data: UpdateData) {
await db.posts.update({
where: { id: postId },
data
});
// Only revalidate specific paths
revalidatePath(`/posts/${postId}`);
revalidatePath('/posts');
revalidateTag(`post-${postId}`);
}
Testing Server Actions
import { describe, it, expect, beforeEach } from 'vitest';
import { createPost, updatePost } from './actions';
describe('Post Actions', () => {
beforeEach(async () => {
await db.posts.deleteMany();
});
it('creates a post successfully', async () => {
const formData = new FormData();
formData.append('title', 'Test Post');
formData.append('content', 'This is test content');
const result = await createPost(formData);
expect(result.success).toBe(true);
expect(result.data.title).toBe('Test Post');
});
it('validates input correctly', async () => {
const formData = new FormData();
formData.append('title', 'AB'); // Too short
const result = await createPost(formData);
expect(result.success).toBe(false);
expect(result.fieldErrors?.title).toBeDefined();
});
it('handles concurrent updates', async () => {
const post = await db.posts.create({
data: { title: 'Test', content: 'Content', version: 1 }
});
await expect(
updatePost(post.id, { content: 'New', version: 0 })
).rejects.toThrow('Version mismatch');
});
});
Real-World Use Cases
Multi-Step Forms
Handle complex workflows seamlessly:
'use server';
export async function submitStep1(formData: FormData) {
const data = validateStep1Data(formData);
const application = await db.applications.create({ data });
return { applicationId: application.id };
}
export async function submitStep2(applicationId: string, formData: FormData) {
const data = validateStep2Data(formData);
await db.applications.update({
where: { id: applicationId },
data: { ...data, step: 2 }
});
return { success: true };
}
export async function submitStep3(applicationId: string, formData: FormData) {
const data = validateStep3Data(formData);
await db.applications.update({
where: { id: applicationId },
data: { ...data, step: 3, status: 'completed' }
});
await sendConfirmationEmail(applicationId);
return { success: true };
}
File Uploads
Secure file handling:
'use server';
import { put } from '@vercel/blob';
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
export async function uploadFile(formData: FormData) {
const file = formData.get('file') as File;
if (!file) {
throw new Error('No file provided');
}
if (file.size > MAX_FILE_SIZE) {
throw new Error('File too large. Maximum size is 10MB');
}
if (!ALLOWED_TYPES.includes(file.type)) {
throw new Error('Invalid file type');
}
const blob = await put(file.name, file, {
access: 'public',
addRandomSuffix: true,
});
await db.uploads.create({
data: {
filename: file.name,
url: blob.url,
size: file.size,
type: file.type,
}
});
return { url: blob.url };
}
Common Pitfalls & Solutions
Pitfall 1: Returning Non-Serializable Data
Problem:
// ❌ Don't do this
export async function getPost() {
return await db.posts.findFirst({
include: { createdAt: true } // Date objects
});
}
Solution:
// ✅ Do this
export async function getPost() {
const post = await db.posts.findFirst();
return {
...post,
createdAt: post.createdAt.toISOString(),
updatedAt: post.updatedAt.toISOString()
};
}
Pitfall 2: Not Handling Race Conditions
Solution:
export async function updatePost(id: string, version: number, data: UpdateData) {
const result = await db.posts.updateMany({
where: {
id,
version // Optimistic locking
},
data: {
...data,
version: version + 1
}
});
if (result.count === 0) {
throw new Error('Post was modified by another user');
}
}
Pitfall 3: Forgetting to Revalidate
Solution:
Always revalidate after mutations:
export async function deleteComment(commentId: string) {
const comment = await db.comments.delete({
where: { id: commentId },
select: { postId: true }
});
// Revalidate all affected paths
revalidatePath(`/posts/${comment.postId}`);
revalidateTag('comments');
revalidateTag(`post-${comment.postId}-comments`);
}
Migration Guide
From API Routes to Server Actions
Before (API Route):
// app/api/posts/route.ts
export async function POST(request: Request) {
const body = await request.json();
const post = await db.posts.create({ data: body });
return Response.json(post);
}
// Client component
async function createPost(data) {
const response = await fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(data)
});
return response.json();
}
After (Server Action):
// actions.ts
'use server';
export async function createPost(formData: FormData) {
const post = await db.posts.create({
data: Object.fromEntries(formData)
});
revalidatePath('/posts');
return post;
}
// Client component
<form action={createPost}>
<input name="title" />
<button>Create</button>
</form>
Conclusion
React 19 Server Actions represent a paradigm shift in full-stack React development. By eliminating the traditional API layer and providing progressive enhancement by default, they enable developers to build more maintainable, performant applications with significantly less code.
Key Takeaways:
As the ecosystem continues to mature, we'll see even more patterns and tooling emerge. Start experimenting with Server Actions today to stay ahead of the curve in modern React development.