React Server Actions enable client components to call server-side functions directly, simplifying data mutations and improving user experience.
What Are Server Actions?
Server Actions are asynchronous functions that run on the server but can be called from client components.
javascript
// Server Action
'use server';
export async function createTodo(formData) {
const title = formData.get('title');
await db.todo.create({ data: { title } });
revalidatePath('/todos');
}
// Client Component
'use client';
import { createTodo } from './actions';
function TodoForm() {
return (
<form action={createTodo}>
<input name="title" type="text" />
<button type="submit">Add</button>
</form>
);
}Defining Server Actions
Basic Action
javascript
// actions/server.ts
'use server';
export async function updateProfile(formData) {
const name = formData.get('name');
const email = formData.get('email');
await db.user.update({
where: { id: userId },
data: { name, email },
});
return { success: true };
}With Explicit Parameters
javascript
'use server';
export async function deleteTodo(id: string) {
await db.todo.delete({ where: { id } });
revalidatePath('/todos');
redirect('/todos');
}Using Server Actions
With Forms
javascript
'use client';
import { createTodo } from './actions';
function TodoForm() {
return (
<form action={createTodo}>
<input name="title" required />
<button type="submit">Create Todo</button>
</form>
);
}With Buttons
javascript
'use client';
import { deleteTodo } from './actions';
function TodoItem({ id, title }) {
return (
<div>
<span>{title}</span>
<form action={deleteTodo.bind(null, id)}>
<button type="submit">Delete</button>
</form>
</div>
);
}With Custom Handlers
javascript
'use client';
import { createTodo } from './actions';
function TodoForm() {
const handleSubmit = async (formData) => {
await createTodo(formData);
// Additional client-side logic
trackEvent('todo_created');
};
return (
<form action={handleSubmit}>
<input name="title" />
<button type="submit">Create</button>
</form>
);
}State Management
useActionState
javascript
'use client';
import { useActionState } from 'react';
function TodoForm() {
const [state, formAction, isPending] = useActionState(
createTodo,
{ error: null, success: false }
);
return (
<form action={formAction}>
<input name="title" />
{state.error && <div className="error">{state.error}</div>}
{state.success && <div className="success">Created!</div>}
<button disabled={isPending}>
{isPending ? 'Creating...' : 'Create'}
</button>
</form>
);
}useFormStatus
javascript
'use client';
import { useFormStatus } from 'react-dom';
function SubmitButton() {
const { pending, data } = useFormStatus();
return (
<button disabled={pending}>
{pending ? 'Submitting...' : 'Submit'}
</button>
);
}
function MyForm() {
return (
<form action={submitData}>
<input name="email" />
<SubmitButton />
</form>
);
}Error Handling
Server-Side Errors
javascript
'use server';
export async function createUser(formData) {
const email = formData.get('email');
const existing = await db.user.findUnique({ where: { email } });
if (existing) {
return { error: 'User already exists' };
}
const user = await db.user.create({
data: { email, name: formData.get('name') },
});
return { success: true, user };
}Client-Side Error Display
javascript
'use client';
import { useActionState } from 'react';
function SignupForm() {
const [state, formAction] = useActionState(createUser, null);
return (
<form action={formAction}>
<input name="email" type="email" />
<input name="name" />
{state?.error && (
<div className="error">{state.error}</div>
)}
<button type="submit">Sign Up</button>
</form>
);
}Optimistic Updates
useOptimistic
javascript
'use client';
import { useOptimistic } from 'react';
function TodoList({ initialTodos }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
initialTodos,
(state, newTodo) => [...state, newTodo]
);
return (
<div>
<form action={async (formData) => {
const newTodo = {
id: Date.now(),
title: formData.get('title'),
pending: true,
};
addOptimisticTodo(newTodo);
await createTodo(formData);
revalidatePath('/todos');
}}>
<input name="title" />
<button type="submit">Add</button>
</form>
<ul>
{optimisticTodos.map(todo => (
<li key={todo.id}>
{todo.title}
{todo.pending && ' (Saving...)'}
</li>
))}
</ul>
</div>
);
}Validation
Server-Side Validation
javascript
'use server';
import { z } from 'zod';
const schema = z.object({
email: z.string().email(),
name: z.string().min(2),
});
export async function createUser(formData) {
const validated = schema.safeParse({
email: formData.get('email'),
name: formData.get('name'),
});
if (!validated.success) {
return { errors: validated.error.flatten() };
}
const user = await db.user.create({
data: validated.data,
});
return { success: true, user };
}Client-Side Validation
javascript
'use client';
import { useFormStatus } from 'react-dom';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
disabled={pending}
formNoValidate={false}
>
{pending ? 'Submitting...' : 'Submit'}
</button>
);
}
function SignupForm() {
return (
<form action={createUser}>
<input
name="email"
type="email"
required
pattern="[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$"
/>
<input
name="name"
required
minLength={2}
/>
<SubmitButton />
</form>
);
}Revalidation
revalidatePath
javascript
'use server';
import { revalidatePath } from 'next/cache';
export async function createPost(formData) {
await db.post.create({
data: {
title: formData.get('title'),
content: formData.get('content'),
},
});
// Revalidate the posts page
revalidatePath('/posts');
revalidatePath('/admin/posts');
}revalidateTag
javascript
'use server';
import { revalidateTag } from 'next/cache';
export async function updateProduct(id, data) {
await db.product.update({
where: { id },
data,
});
// Revalidate all cached data with this tag
revalidateTag('products');
}
// In fetch
async function Products() {
const products = await fetch('https://api.com/products', {
next: { tags: ['products'] },
}).then(r => r.json());
return <ProductList products={products} />;
}Redirects
After Action
javascript
'use server';
import { redirect } from 'next/navigation';
export async function deletePost(id) {
await db.post.delete({ where: { id } });
revalidatePath('/posts');
redirect('/posts');
}Conditional Redirect
javascript
'use server';
import { redirect } from 'next/navigation';
export async function updatePost(id, formData) {
const post = await db.post.update({
where: { id },
data: { title: formData.get('title') },
});
if (formData.get('redirectTo') === 'preview') {
redirect(`/posts/${id}/preview`);
} else {
redirect(`/posts/${id}`);
}
}Progressive Enhancement
Server Actions as Fallback
javascript
'use client';
import { useState } from 'react';
import { createTodo } from './actions';
function TodoForm() {
const [todos, setTodos] = useState([]);
const handleSubmit = async (formData) => {
// Optimistic update
const optimisticTodo = {
id: Date.now(),
title: formData.get('title'),
};
setTodos(prev => [...prev, optimisticTodo]);
// Server action
await createTodo(formData);
// Refresh data
mutate('/api/todos');
};
return (
<form action={handleSubmit}>
<input name="title" />
<button type="submit">Add</button>
</form>
);
}Best Practices
DOs
- Use Server Actions for mutations
- Validate data on the server
- Handle errors gracefully
- Provide optimistic updates
- Revalidate relevant paths
- Use type safety
- Implement proper authentication
DON'Ts
- Fetch data with Server Actions (use Server Components)
- Ignore error handling
- Skip validation
- Forget revalidation
- Overuse for simple operations
- Mix client and server logic
- Expose sensitive operations
Security
CSRF Protection
Server Actions automatically include CSRF tokens.
javascript
// No additional code needed
// Server Actions are protected by defaultInput Validation
javascript
'use server';
import { z } from 'zod';
const schema = z.object({
title: z.string().min(1).max(100),
content: z.string().min(10),
});
export async function createPost(formData) {
const validated = schema.safeParse(Object.fromEntries(formData));
if (!validated.success) {
return { error: 'Invalid input' };
}
// Process validated data
}Real-World Example
Complete CRUD
javascript
// actions/todos.ts
'use server';
export async function getTodos() {
return await db.todo.findMany();
}
export async function createTodo(formData) {
const title = formData.get('title');
await db.todo.create({ data: { title } });
revalidatePath('/todos');
}
export async function updateTodo(id, formData) {
const title = formData.get('title');
await db.todo.update({ where: { id }, data: { title } });
revalidatePath('/todos');
}
export async function deleteTodo(id) {
await db.todo.delete({ where: { id } });
revalidatePath('/todos');
}javascript
// components/TodoApp.tsx
'use client';
import { useActionState } from 'react';
import { createTodo, deleteTodo } from './actions';
function TodoApp({ initialTodos }) {
const [state, formAction] = useActionState(createTodo, null);
return (
<div>
<form action={formAction}>
<input name="title" />
<button type="submit">Add</button>
</form>
<ul>
{initialTodos.map(todo => (
<li key={todo.id}>
{todo.title}
<form action={deleteTodo.bind(null, todo.id)}>
<button type="submit">×</button>
</form>
</li>
))}
</ul>
</div>
);
}Conclusion
Server Actions simplify client-server communication in React. Use them for mutations, leverage built-in hooks for better UX, and always validate on the server. They're the future of data mutations in React!