Next.js 15 Server Actions: Complete Guide with Real Examples (2026)
Server Actions changed everything about how I build Next.js applications. No more API routes for simple data mutations. No more boilerplate…
Next.js 15 Server Actions: Complete Guide with Real Examples (2026)
Server Actions changed everything about how I build Next.js applications. No more API routes for simple data mutations. No more boilerplate for form handling. Just write a function, add 'use server', and call it from your components.
But here’s what nobody tells you: Server Actions are deceptively simple on the surface, yet filled with gotchas that can break your app in production. I’ve spent the past year building applications with Server Actions at Appzivo, and I’ve learned these lessons the hard way — through bugs, performance issues, and late-night debugging sessions.
This isn’t another basic tutorial that shows you how to create a todo app. This is the comprehensive guide I wish I had when I started — covering real-world patterns, common mistakes, security considerations, and production-ready code you can actually use.
What Are Server Actions (And Why They Matter)
Server Actions are server-side functions you can call directly from client components. That might sound simple, but it fundamentally changes how you build full-stack Next.js applications.
Before Server Actions, here’s what a typical form submission looked like:
The Old Way (API Routes):
// pages/api/users.ts
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
const { name, email } = req.body;
// Validation logic
// Database mutation
// Error handling
res.status(200).json({ success: true });
}
// components/UserForm.tsx
'use client'
export function UserForm() {
const handleSubmit = async (e) => {
e.preventDefault();
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email })
});
// Handle response
}
return <form onSubmit={handleSubmit}>...</form>
}
That’s a lot of boilerplate for something simple. Now look at the Server Actions approach:
The New Way (Server Actions):
// app/actions.ts
'use server'
export async function createUser(formData: FormData) {
const name = formData.get('name');
const email = formData.get('email');
// Validation and database mutation
revalidatePath('/users');
}
// components/UserForm.tsx
import { createUser } from '@/app/actions';
export function UserForm() {
return (
<form action={createUser}>
<input name="name" />
<input name="email" />
<button type="submit">Create</button>
</form>
);
}
Less code. Better developer experience. Automatic progressive enhancement — forms work even before JavaScript loads.
Understanding the ‘use server’ Directive
The 'use server' directive is how you tell Next.js that a function should execute on the server. But there are two ways to use it, and understanding the difference is crucial.
Method 1: File-Level Directive
Place 'use server' at the top of a file, and every exported function becomes a Server Action:
'use server'
export async function createPost(formData: FormData) {
// This is a Server Action
}
export async function deletePost(id: string) {
// This is also a Server Action
}
export async function updatePost(id: string, data: any) {
// This too
}
This is the cleanest approach when you have multiple related Server Actions. I typically create an actions.ts file for each feature area.
Method 2: Function-Level Directive
Place 'use server' inside a specific function:
export async function MyComponent() {
async function handleSubmit(formData: FormData) {
'use server'
// Only this function is a Server Action
}
return <form action={handleSubmit}>...</form>
}
Use this when you need a Server Action that’s tightly coupled to a specific component and won’t be reused elsewhere.
Important Security Note: Even unused Server Actions expose public HTTP endpoints. In Next.js 15, unused actions are automatically removed during build, but you should still be mindful about what you export.
Working with FormData: The Foundation
Server Actions shine when handling forms. When you pass a Server Action to the action prop, Next.js automatically provides a FormData object:
'use server'
export async function createInvoice(formData: FormData) {
// Extract individual fields
const customerId = formData.get('customerId') as string;
const amount = formData.get('amount') as string;
const status = formData.get('status') as string;
// Or convert entire form to object
const rawFormData = Object.fromEntries(formData);
console.log(rawFormData);
// { customerId: '123', amount: '1000', status: 'pending' }
// Mutate database
await db.invoices.create({
customerId,
amount: parseFloat(amount),
status
});
revalidatePath('/invoices');
}
export function InvoiceForm() {
return (
<form action={createInvoice}>
<select name="customerId">
<option value="123">Customer A</option>
<option value="456">Customer B</option>
</select>
<input
type="number"
name="amount"
placeholder="Amount"
required
/>
<select name="status">
<option value="pending">Pending</option>
<option value="paid">Paid</option>
</select>
<button type="submit">Create Invoice</button>
</form>
);
}
FormData works seamlessly with standard HTML inputs, but you need to be careful with:
- Checkboxes (use
.getAll()for multiple values) - File uploads (files become File objects)
- Hidden fields (useful for passing non-user-visible data)
Calling Server Actions Outside Forms
Server Actions aren’t limited to forms. You can invoke them from event handlers, useEffect, or anywhere you can call an async function:
'use client'
import { incrementLikes } from '@/app/actions';
import { useState } from 'react';
export function LikeButton({ postId, initialLikes }: Props) {
const [likes, setLikes] = useState(initialLikes);
const [isLiking, setIsLiking] = useState(false);
async function handleLike() {
setIsLiking(true);
try {
const newLikes = await incrementLikes(postId);
setLikes(newLikes);
} catch (error) {
console.error('Failed to like post:', error);
} finally {
setIsLiking(false);
}
}
return (
<button
onClick={handleLike}
disabled={isLiking}
>
{isLiking ? '...' : `❤️ ${likes}`}
</button>
);
}
The Server Action:
'use server'
export async function incrementLikes(postId: string) {
const post = await db.posts.update({
where: { id: postId },
data: { likes: { increment: 1 } }
});
revalidatePath(`/posts/${postId}`);
return post.likes;
}
This pattern is perfect for:
- Like/favorite buttons
- Real-time updates
- Background syncing
- Polling for data changes
Error Handling: The Right Way
Error handling in Server Actions is where most developers stumble. The key insight: don’t throw errors for expected validation failures. Throwing errors triggers React Error Boundaries, which is terrible UX for simple validation mistakes.
Bad Pattern (Don’t Do This):
'use server'
export async function createUser(formData: FormData) {
const email = formData.get('email');
if (!email) {
throw new Error('Email is required'); // ❌ Bad!
}
// This triggers error.tsx, showing a full error page
// User loses all form data
}
Good Pattern (Return Error States):
'use server'
type ActionResult = {
success: boolean;
error?: string;
user?: User;
}
export async function createUser(
prevState: ActionResult,
formData: FormData
): Promise<ActionResult> {
const email = formData.get('email') as string;
// Validation
if (!email) {
return {
success: false,
error: 'Email is required'
};
}
if (!email.includes('@')) {
return {
success: false,
error: 'Invalid email format'
};
}
try {
const user = await db.users.create({ email });
revalidatePath('/users');
return {
success: true,
user
};
} catch (error) {
// Database errors or unexpected issues
return {
success: false,
error: 'Failed to create user. Please try again.'
};
}
}
Client-side usage with useActionState:
'use client'
import { useActionState } from 'react';
import { createUser } from '@/app/actions';
const initialState = { success: false };
export function UserForm() {
const [state, formAction, pending] = useActionState(
createUser,
initialState
);
return (
<form action={formAction}>
<input
type="email"
name="email"
required
/>
{state.error && (
<p className="text-red-500">{state.error}</p>
)}
{state.success && (
<p className="text-green-500">User created!</p>
)}
<button disabled={pending}>
{pending ? 'Creating...' : 'Create User'}
</button>
</form>
);
}
The useActionState hook (React 19) gives you:
state: The returned value from your Server ActionformAction: The wrapped action to pass to your formpending: Boolean indicating if the action is currently executing
Validation with Zod: Production Pattern
For real applications, use a proper validation library. Here’s the pattern I use in production with Zod:
'use server'
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
const createPostSchema = z.object({
title: z.string().min(3, 'Title must be at least 3 characters'),
content: z.string().min(10, 'Content must be at least 10 characters'),
published: z.boolean().default(false),
tags: z.array(z.string()).optional(),
});
type CreatePostResult = {
success: boolean;
errors?: {
title?: string[];
content?: string[];
_form?: string[];
};
post?: Post;
}
export async function createPost(
prevState: CreatePostResult,
formData: FormData
): Promise<CreatePostResult> {
// Parse and validate
const validatedFields = createPostSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
published: formData.get('published') === 'on',
});
// Return field-specific errors
if (!validatedFields.success) {
return {
success: false,
errors: validatedFields.error.flatten().fieldErrors,
};
}
// Type-safe data
const { title, content, published } = validatedFields.data;
try {
const post = await db.posts.create({
data: { title, content, published }
});
revalidatePath('/posts');
return {
success: true,
post
};
} catch (error) {
return {
success: false,
errors: {
_form: ['Failed to create post. Please try again.']
}
};
}
}
Client component:
'use client'
import { useActionState } from 'react';
import { createPost } from '@/app/actions';
export function PostForm() {
const [state, formAction, pending] = useActionState(
createPost,
{ success: false }
);
return (
<form action={formAction} className="space-y-4">
<div>
<label htmlFor="title">Title</label>
<input
id="title"
name="title"
type="text"
required
/>
{state.errors?.title && (
<p className="text-red-500">{state.errors.title[0]}</p>
)}
</div>
<div>
<label htmlFor="content">Content</label>
<textarea
id="content"
name="content"
required
/>
{state.errors?.content && (
<p className="text-red-500">{state.errors.content[0]}</p>
)}
</div>
<div>
<label>
<input type="checkbox" name="published" />
Publish immediately
</label>
</div>
{state.errors?._form && (
<p className="text-red-500">{state.errors._form[0]}</p>
)}
{state.success && (
<p className="text-green-500">Post created successfully!</p>
)}
<button type="submit" disabled={pending}>
{pending ? 'Creating...' : 'Create Post'}
</button>
</form>
);
}
Passing Additional Arguments
Sometimes you need to pass data beyond what’s in the form. Use JavaScript’s bind method:
'use client'
import { updateUser } from '@/app/actions';
export function UserProfile({ userId }: { userId: string }) {
// Bind userId as first argument
const updateUserWithId = updateUser.bind(null, userId);
return (
<form action={updateUserWithId}>
<input name="name" type="text" />
<button type="submit">Update Name</button>
</form>
);
}
Server Action:
'use server'
export async function updateUser(
userId: string, // From bind
formData: FormData // From form
) {
const name = formData.get('name') as string;
await db.users.update({
where: { id: userId },
data: { name }
});
revalidatePath(`/users/${userId}`);
}
This pattern is essential when you need context (user ID, post ID, etc.) that isn’t part of the form data itself.
Loading States and Optimistic Updates
Users need feedback. Here’s how to provide it properly.
Basic Loading State:
'use client'
import { useActionState } from 'react';
import { createPost } from '@/app/actions';
export function PostForm() {
const [state, formAction, pending] = useActionState(
createPost,
{ success: false }
);
return (
<form action={formAction}>
<input name="title" disabled={pending} />
<textarea name="content" disabled={pending} />
<button disabled={pending}>
{pending ? (
<>
<Spinner /> Creating...
</>
) : (
'Create Post'
)}
</button>
</form>
);
}
Optimistic Updates:
For better perceived performance, update the UI immediately before the server responds:
'use client'
import { useOptimistic } from 'react';
import { addTodo } from '@/app/actions';
export function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(state, newTodo: string) => [
...state,
{ id: Date.now(), text: newTodo, completed: false }
]
);
async function formAction(formData: FormData) {
const text = formData.get('todo') as string;
// Optimistically add to UI
addOptimisticTodo(text);
// Server mutation happens in background
await addTodo(formData);
}
return (
<>
<form action={formAction}>
<input name="todo" />
<button>Add</button>
</form>
<ul>
{optimisticTodos.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
</>
);
}
The optimistic update appears instantly, while the actual server mutation happens in the background. If the server action fails, React automatically reverts the optimistic update.
Cache Revalidation: Keeping Data Fresh
After mutations, you need to update cached data. Next.js provides two functions:
revalidatePath:
Revalidate specific routes:
'use server'
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
// Create post...
// Revalidate the posts list page
revalidatePath('/posts');
// Revalidate specific post if updating
revalidatePath(`/posts/${postId}`);
// Revalidate layout and all nested pages
revalidatePath('/dashboard', 'layout');
}
revalidateTag:
For more granular control, use cache tags:
// Fetch with tag
export async function getPosts() {
const posts = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] }
});
return posts.json();
}
// Revalidate by tag
'use server'
import { revalidateTag } from 'next/cache';
export async function createPost(formData: FormData) {
// Create post...
// Revalidate all data tagged with 'posts'
revalidateTag('posts');
}
Use tags when you have related data fetched in multiple places that should all revalidate together.
Common Mistakes (And How to Avoid Them)
After building dozens of applications with Server Actions, I’ve seen these mistakes repeatedly:
1. Not Showing Loading States
Bad:
<button type="submit">Submit</button>
Users click multiple times, causing duplicate submissions.
Good:
const [, , pending] = useActionState(action, initialState);
<button disabled={pending}>
{pending ? 'Submitting...' : 'Submit'}
</button>
2. Using redirect Inside try/catch
Bad:
try {
await db.create(data);
redirect('/success'); // ❌ Gets caught!
} catch (error) {
return { error: 'Failed' };
}
The redirect function throws an error internally, which your catch block intercepts.
Good:
try {
await db.create(data);
} catch (error) {
return { error: 'Failed' };
}
redirect('/success'); // ✅ Outside try/catch
3. Exposing Sensitive Data
Bad:
'use server'
export async function getUser(id: string) {
const user = await db.users.findUnique({
where: { id },
select: {
email: true,
password: true, // ❌ Never expose!
apiKey: true, // ❌ Never expose!
}
});
return user; // Sent to client
}
Good:
'use server'
export async function getUser(id: string) {
const user = await db.users.findUnique({
where: { id },
select: {
id: true,
email: true,
name: true,
// Only public fields
}
});
return user;
}
4. Not Validating Inputs
Bad:
'use server'
export async function updateSettings(formData: FormData) {
const value = formData.get('setting');
await db.settings.update({ value }); // ❌ No validation!
}
Always validate. Always. Users can manipulate form data in DevTools.
Good:
'use server'
const schema = z.object({
setting: z.enum(['option1', 'option2', 'option3'])
});
export async function updateSettings(formData: FormData) {
const validated = schema.safeParse({
setting: formData.get('setting')
});
if (!validated.success) {
return { error: 'Invalid input' };
}
await db.settings.update(validated.data);
}
5. Forgetting to Revalidate
Bad:
'use server'
export async function createPost(formData: FormData) {
await db.posts.create(data);
// ❌ Cache not revalidated, user sees stale data
}
Good:
'use server'
export async function createPost(formData: FormData) {
await db.posts.create(data);
revalidatePath('/posts'); // ✅ Fresh data
}
Security Considerations
Server Actions expose public HTTP endpoints. Treat them with the same security mindset as API routes.
Authentication:
Always verify the user is authenticated:
'use server'
import { auth } from '@/lib/auth';
export async function deletePost(postId: string) {
const session = await auth();
if (!session?.user) {
throw new Error('Unauthorized');
}
// Verify user owns the post
const post = await db.posts.findUnique({
where: { id: postId }
});
if (post.authorId !== session.user.id) {
throw new Error('Forbidden');
}
await db.posts.delete({ where: { id: postId } });
revalidatePath('/posts');
}
Rate Limiting:
For production apps, implement rate limiting:
'use server'
import { ratelimit } from '@/lib/redis';
export async function sendEmail(formData: FormData) {
const session = await auth();
// Allow 5 emails per hour per user
const { success } = await ratelimit.limit(
`email:${session.user.id}`
);
if (!success) {
return { error: 'Rate limit exceeded' };
}
// Send email...
}
Input Sanitization:
Never trust user input. Sanitize everything:
'use server'
import { sanitize } from 'isomorphic-dompurify';
export async function createComment(formData: FormData) {
const rawContent = formData.get('content') as string;
// Strip HTML tags and dangerous content
const content = sanitize(rawContent);
await db.comments.create({ content });
}
Testing Server Actions
Server Actions are just async functions, making them straightforward to test:
import { expect, test, vi } from 'vitest';
import { createPost } from './actions';
// Mock database
vi.mock('@/lib/db', () => ({
db: {
posts: {
create: vi.fn()
}
}
}));
test('createPost creates a post', async () => {
const formData = new FormData();
formData.append('title', 'Test Post');
formData.append('content', 'Test content');
const result = await createPost({ success: false }, formData);
expect(result.success).toBe(true);
expect(db.posts.create).toHaveBeenCalledWith({
data: {
title: 'Test Post',
content: 'Test content',
published: false
}
});
});
test('createPost returns error for invalid input', async () => {
const formData = new FormData();
formData.append('title', 'ab'); // Too short
const result = await createPost({ success: false }, formData);
expect(result.success).toBe(false);
expect(result.errors?.title).toBeDefined();
});
Advanced Pattern: Type-Safe Server Actions
For maximum type safety, use a library like next-safe-action:
'use server'
import { createSafeAction } from 'next-safe-action';
import { z } from 'zod';
const createPostSchema = z.object({
title: z.string().min(3),
content: z.string().min(10),
});
export const createPost = createSafeAction(
createPostSchema,
async ({ title, content }) => {
// TypeScript knows title and content are validated strings
const post = await db.posts.create({
data: { title, content }
});
revalidatePath('/posts');
return { post };
}
);
Client usage:
'use client'
import { useAction } from 'next-safe-action/hooks';
import { createPost } from '@/app/actions';
export function PostForm() {
const { execute, status, result } = useAction(createPost);
return (
<form
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
execute({
title: formData.get('title') as string,
content: formData.get('content') as string,
});
}}
>
<input name="title" />
<textarea name="content" />
{result.validationErrors && (
<p>{JSON.stringify(result.validationErrors)}</p>
)}
<button disabled={status === 'executing'}>
{status === 'executing' ? 'Creating...' : 'Create'}
</button>
</form>
);
}
This provides end-to-end type safety from client to server with automatic validation and error handling.
When NOT to Use Server Actions
Server Actions aren’t always the answer. Avoid them for:
Real-time features: Use WebSockets or Server-Sent Events instead File uploads over 1MB: The default body size limit is 1MB (configurable, but consider direct uploads) Long-running tasks: Actions time out. Use background jobs instead Public APIs: Use API routes for third-party consumption Streaming responses: Server Actions return single values, not streams
Performance Optimization
Debounce Rapid Calls:
'use client'
import { useDebouncedCallback } from 'use-debounce';
import { searchPosts } from '@/app/actions';
export function SearchBar() {
const handleSearch = useDebouncedCallback(
async (term: string) => {
await searchPosts(term);
},
300 // Wait 300ms after user stops typing
);
return (
<input
onChange={(e) => handleSearch(e.target.value)}
placeholder="Search..."
/>
);
}
Batch Operations:
Instead of calling an action multiple times, batch operations:
'use server'
// Bad: Multiple calls
export async function deletePost(id: string) {
await db.posts.delete({ where: { id } });
}
// Good: Batch delete
export async function deletePosts(ids: string[]) {
await db.posts.deleteMany({
where: { id: { in: ids } }
});
revalidatePath('/posts');
}
The Bottom Line
Server Actions represent a paradigm shift in how we build Next.js applications. They eliminate API route boilerplate, provide automatic progressive enhancement, and integrate seamlessly with React’s concurrent features.
But power comes with responsibility. Validate inputs. Handle errors properly. Implement authentication. Show loading states. Test thoroughly.
The patterns in this guide come from building real applications in production. They’ll save you from the mistakes I made and help you build robust, user-friendly applications.
Start simple. Add a Server Action to handle a form submission. Then gradually adopt the more advanced patterns — validation with Zod, optimistic updates, proper error handling — as your application grows in complexity.
Server Actions aren’t just a new feature. They’re a better way to build full-stack Next.js applications. Master them, and you’ll wonder how you ever built apps without them.
Ready to implement? Start with a simple form mutation today. Apply the error handling pattern. Add proper validation. Your users will thank you, and your codebase will be cleaner for it.
메타데이터
- post_id
- 6320fbfa01c3
- slug
- next-js-15-server-actions-complete-guide-with-real-examples-2026-6320fbfa01c3
- url
- https://medium.com/@saad.minhas.codes/next-js-15-server-actions-complete-guide-with-real-examples-2026-6320fbfa01c3
- canonical_url
- https://medium.com/@saad.minhas.codes/next-js-15-server-actions-complete-guide-with-real-examples-2026-6320fbfa01c3
- author_url
- https://medium.com/@saad.minhas.codes
- status
- ok
- fetched_at
- 2026-06-09 15:37:30