Server Actions & Mutations
Handle form submissions and data mutations with Server Actions
What are Server Actions?
Server Actions are asynchronous functions that execute on the server. They can be used in Server and Client Components to handle form submissions and data mutations. No need to create API routes for basic CRUD operations!
⚡ Server Actions Benefits
- ✅ Progressive enhancement - works without JavaScript
- ✅ Type-safe with TypeScript
- ✅ Integrated with Next.js caching
- ✅ No API boilerplate needed
- ✅ Automatic form validation
Basic Server Action
// app/actions.ts
'use server'; // Mark the entire file as server actions
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { db } from '@/lib/db';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
// Validate
if (!title || title.length < 3) {
return { error: 'Title must be at least 3 characters' };
}
// Create in database
const post = await db.post.create({
data: { title, content },
});
// Revalidate and redirect
revalidatePath('/posts');
redirect(`/posts/${post.id}`);
}
// Using in a Server Component
// app/posts/new/page.tsx
import { createPost } from '@/app/actions';
export default function NewPost() {
return (
<form action={createPost}>
<input name="title" placeholder="Title" required />
<textarea name="content" placeholder="Content" />
<button type="submit">Create Post</button>
</form>
);
}
useActionState for Form State
'use client';
import { useActionState } from 'react';
import { createUser } from '@/app/actions';
// Action with state
// app/actions.ts
'use server';
export async function createUser(
prevState: { error?: string; success?: boolean },
formData: FormData
) {
const email = formData.get('email') as string;
if (!email.includes('@')) {
return { error: 'Invalid email address' };
}
try {
await db.user.create({ data: { email } });
return { success: true };
} catch (e) {
return { error: 'Email already exists' };
}
}
// Client Component with form state
export default function SignupForm() {
const [state, formAction, isPending] = useActionState(createUser, {});
return (
<form action={formAction}>
<input
name="email"
type="email"
placeholder="Email"
disabled={isPending}
/>
{state.error && (
<p className="text-red-500">{state.error}</p>
)}
{state.success && (
<p className="text-green-500">User created!</p>
)}
<button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Sign Up'}
</button>
</form>
);
}
useFormStatus for Pending State
'use client';
import { useFormStatus } from 'react-dom';
// Submit button component
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className={pending ? 'opacity-50' : ''}
>
{pending ? 'Submitting...' : 'Submit'}
</button>
);
}
// Form component
export default function ContactForm() {
return (
<form action={sendMessage}>
<input name="message" placeholder="Message" />
<SubmitButton /> {/* Must be inside the form */}
</form>
);
}
Optimistic Updates
'use client';
import { useOptimistic } from 'react';
import { addTodo } from '@/app/actions';
interface Todo {
id: string;
text: string;
pending?: boolean;
}
export default function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(state: Todo[], newTodo: string) => [
...state,
{ id: crypto.randomUUID(), text: newTodo, pending: true },
]
);
async function formAction(formData: FormData) {
const text = formData.get('text') as string;
addOptimisticTodo(text); // Update UI immediately
await addTodo(text); // Then persist to server
}
return (
<div>
<form action={formAction}>
<input name="text" placeholder="Add todo..." />
<button type="submit">Add</button>
</form>
<ul>
{optimisticTodos.map(todo => (
<li
key={todo.id}
className={todo.pending ? 'opacity-50' : ''}
>
{todo.text}
</li>
))}
</ul>
</div>
);
}
Validation with Zod
// app/actions.ts
'use server';
import { z } from 'zod';
const PostSchema = z.object({
title: z.string().min(3).max(100),
content: z.string().min(10),
category: z.enum(['tech', 'lifestyle', 'news']),
});
export async function createPost(
prevState: { errors?: z.ZodError['errors'] },
formData: FormData
) {
const rawData = {
title: formData.get('title'),
content: formData.get('content'),
category: formData.get('category'),
};
const result = PostSchema.safeParse(rawData);
if (!result.success) {
return { errors: result.error.errors };
}
// Data is validated and typed!
const { title, content, category } = result.data;
await db.post.create({
data: { title, content, category },
});
revalidatePath('/posts');
redirect('/posts');
}
Calling Actions Programmatically
'use client';
import { deletePost, likePost } from '@/app/actions';
import { useTransition } from 'react';
export function PostActions({ postId }: { postId: string }) {
const [isPending, startTransition] = useTransition();
const handleDelete = () => {
if (!confirm('Delete this post?')) return;
startTransition(async () => {
await deletePost(postId);
});
};
const handleLike = () => {
startTransition(async () => {
await likePost(postId);
});
};
return (
<div>
<button
onClick={handleLike}
disabled={isPending}
>
❤️ Like
</button>
<button
onClick={handleDelete}
disabled={isPending}
className="text-red-500"
>
🗑️ Delete
</button>
</div>
);
}
✅ Server Actions Best Practices
- • Always validate input on the server
- • Use useActionState for form feedback
- • Use useFormStatus for loading states
- • Use useOptimistic for instant UI updates
- • Revalidate cache after mutations
- • Handle errors gracefully