Server Actions are asynchronous functions that run on the server. They replace traditional API routes for form submissions and data mutations, providing type safety, progressive enhancement (work without JavaScript!), and seamless integration with React components. You write functions, Next.js handles the client-server communication automatically. Let's master Server Actions!
What Are Server Actions?
❌ Traditional API Routes
// API route: app/api/posts/route.ts
export async function POST(request: Request) {
const data = await request.json();
// Process data...
return Response.json({ success: true });
}
// Client component
async function handleSubmit() {
const response = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
}
// Problems:
// ❌ Separate API file
// ❌ Manual fetch logic
// ❌ No type safety
// ❌ Requires JavaScript
// ❌ More boilerplate✅ Server Actions
// Server Action (co-located)
'use server';
async function createPost(formData: FormData) {
const title = formData.get('title');
// Process data...
return { success: true };
}
// Use in component
<form action={createPost}>
<input name="title" />
<button>Submit</button>
</form>
// Benefits:
// ✅ Co-located with component
// ✅ Automatic communication
// ✅ Type-safe
// ✅ Works without JavaScript
// ✅ Less codeKey Benefits of Server Actions
- Type safety: Full TypeScript support from client to server
- Progressive enhancement: Forms work without JavaScript
- Co-location: Define actions near components that use them
- Automatic serialization: No manual JSON.stringify/parse
- Integrated caching: Works with Next.js cache and revalidation
- Streaming: Can return multiple values over time
Basic Server Action Usage
Method 1: Inline Server Action
// Server Component with inline Server Action
export default function Page() {
async function createPost(formData: FormData) {
'use server';
const title = formData.get('title') as string;
const content = formData.get('content') as string;
// Database operation
await db.posts.create({
data: { title, content },
});
console.log('Post created:', title);
}
return (
<form action={createPost}>
<input name="title" placeholder="Title" required />
<textarea name="content" placeholder="Content" required />
<button type="submit">Create Post</button>
</form>
);
}
// ✅ 'use server' marks function as Server Action
// ✅ Runs on server when form submits
// ✅ Works without JavaScript
// ✅ Automatic serializationMethod 2: Separate Actions File
'use server';
// All exported functions are Server Actions
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 },
});
return { success: true };
}
export async function deletePost(postId: string) {
await db.posts.delete({
where: { id: postId },
});
return { success: true };
}
export async function updatePost(postId: string, formData: FormData) {
const title = formData.get('title') as string;
await db.posts.update({
where: { id: postId },
data: { title },
});
return { success: true };
}
// ✅ 'use server' at top applies to entire file
// ✅ All exports are Server Actions
// ✅ Organized by domain (posts, users, etc.)
// ✅ Reusable across componentsimport { createPost, deletePost } from '@/app/actions/posts';
export default function BlogPage() {
return (
<div>
{/* Create form */}
<form action={createPost}>
<input name="title" />
<button>Create</button>
</form>
{/* Delete form */}
<form action={deletePost.bind(null, 'post-id-123')}>
<button>Delete</button>
</form>
</div>
);
}
// ✅ Import Server Actions
// ✅ Use directly in forms
// ✅ .bind() for passing additional argumentsServer Actions in Client Components
Calling from Client Component
'use server';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
await db.posts.create({
data: { title },
});
return { success: true, message: 'Post created!' };
}'use client';
import { createPost } from '@/app/actions/posts';
import { useState } from 'react';
export function CreatePostForm() {
const [message, setMessage] = useState('');
async function handleSubmit(formData: FormData) {
const result = await createPost(formData);
setMessage(result.message);
}
return (
<form action={handleSubmit}>
<input name="title" required />
<button type="submit">Create</button>
{message && <p className="text-green-600">{message}</p>}
</form>
);
}
// ✅ Import Server Action in Client Component
// ✅ Call like regular async function
// ✅ Handle response with state
// ✅ Full type safetyProgrammatic Calling
'use client';
import { deletePost } from '@/app/actions/posts';
import { useState } from 'react';
export function DeleteButton({ postId }: { postId: string }) {
const [loading, setLoading] = useState(false);
async function handleDelete() {
if (!confirm('Delete this post?')) return;
setLoading(true);
try {
await deletePost(postId);
alert('Post deleted!');
} catch (error) {
alert('Failed to delete');
} finally {
setLoading(false);
}
}
return (
<button
onClick={handleDelete}
disabled={loading}
className="px-4 py-2 bg-red-600 text-white rounded disabled:bg-gray-400"
>
{loading ? 'Deleting...' : 'Delete'}
</button>
);
}
// ✅ Call Server Action from onClick
// ✅ Handle loading state
// ✅ Error handling with try-catch
// ✅ User confirmationPassing Arguments to Server Actions
Pattern 1: FormData (Forms)
'use server';
export async function createPost(formData: FormData) {
// Extract from FormData
const title = formData.get('title') as string;
const content = formData.get('content') as string;
const published = formData.get('published') === 'on';
await db.posts.create({
data: { title, content, published },
});
}
// Usage in form:
<form action={createPost}>
<input name="title" />
<textarea name="content" />
<input type="checkbox" name="published" />
<button>Submit</button>
</form>
// ✅ FormData for forms
// ✅ Automatic from form fields
// ✅ Works without JavaScriptPattern 2: Regular Arguments
'use server';
export async function updatePost(
postId: string,
title: string,
content: string
) {
await db.posts.update({
where: { id: postId },
data: { title, content },
});
return { success: true };
}
// Usage programmatically:
await updatePost('post-123', 'New Title', 'New Content');
// ✅ Type-safe arguments
// ✅ Clean function signature
// ✅ Good for programmatic callsPattern 3: .bind() for Additional Arguments
'use server';
export async function updatePost(postId: string, formData: FormData) {
const title = formData.get('title') as string;
await db.posts.update({
where: { id: postId },
data: { title },
});
}
// Usage with .bind() to pass postId:
<form action={updatePost.bind(null, 'post-123')}>
<input name="title" />
<button>Update</button>
</form>
// ✅ .bind() pre-fills first argument
// ✅ Useful for IDs with forms
// ✅ Maintains progressive enhancementPattern 4: Hidden Input Fields
'use server';
export async function updatePost(formData: FormData) {
const postId = formData.get('postId') as string;
const title = formData.get('title') as string;
await db.posts.update({
where: { id: postId },
data: { title },
});
}
// Usage with hidden input:
<form action={updatePost}>
<input type="hidden" name="postId" value="post-123" />
<input name="title" />
<button>Update</button>
</form>
// ✅ Hidden inputs for IDs
// ✅ All data in FormData
// ✅ Works without JavaScriptReturn Values and Responses
Returning Data
'use server';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const post = await db.posts.create({
data: { title },
});
// Return serializable data
return {
success: true,
postId: post.id,
message: 'Post created successfully!',
};
}
// Usage:
const result = await createPost(formData);
console.log(result.message); // "Post created successfully!"
// ✅ Return plain objects
// ✅ Automatically serialized
// ✅ Type-safeError Handling
'use server';
export async function createPost(formData: FormData) {
try {
const title = formData.get('title') as string;
if (!title) {
return {
success: false,
error: 'Title is required',
};
}
await db.posts.create({
data: { title },
});
return {
success: true,
message: 'Post created!',
};
} catch (error) {
console.error('Failed to create post:', error);
return {
success: false,
error: 'Failed to create post',
};
}
}
// Usage:
const result = await createPost(formData);
if (result.success) {
alert(result.message);
} else {
alert(result.error);
}
// ✅ Return success/error states
// ✅ Handle errors gracefully
// ✅ Informative error messagesRedirecting After Action
'use server';
import { redirect } from 'next/navigation';
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const post = await db.posts.create({
data: { title },
});
// Revalidate blog page cache
revalidatePath('/blog');
// Redirect to new post
redirect(`/blog/${post.id}`);
}
// ✅ redirect() after successful action
// ✅ revalidatePath() to update cached data
// ✅ Automatic navigationPractical Examples
Example 1: Simple Todo App
'use server';
import { revalidatePath } from 'next/cache';
export async function addTodo(formData: FormData) {
const text = formData.get('text') as string;
await db.todos.create({
data: {
text,
completed: false,
},
});
revalidatePath('/todos');
}
export async function toggleTodo(todoId: string) {
const todo = await db.todos.findUnique({
where: { id: todoId },
});
await db.todos.update({
where: { id: todoId },
data: {
completed: !todo?.completed,
},
});
revalidatePath('/todos');
}
export async function deleteTodo(todoId: string) {
await db.todos.delete({
where: { id: todoId },
});
revalidatePath('/todos');
}
// ✅ Three Server Actions for CRUD
// ✅ revalidatePath() to refresh data
// ✅ Simple, clean codeimport { addTodo, toggleTodo, deleteTodo } from '@/app/actions/todos';
async function getTodos() {
return await db.todos.findMany();
}
export default async function TodosPage() {
const todos = await getTodos();
return (
<div className="max-w-2xl mx-auto p-8">
<h1 className="text-3xl font-bold mb-8">Todos</h1>
{/* Add form */}
<form action={addTodo} className="mb-8">
<input
name="text"
placeholder="What needs to be done?"
required
className="w-full px-4 py-2 border rounded"
/>
<button
type="submit"
className="mt-2 px-6 py-2 bg-blue-600 text-white rounded"
>
Add Todo
</button>
</form>
{/* Todos list */}
<div className="space-y-2">
{todos.map(todo => (
<div
key={todo.id}
className="flex items-center gap-4 p-4 bg-white rounded shadow"
>
{/* Toggle form */}
<form action={toggleTodo.bind(null, todo.id)}>
<button
type="submit"
className={`w-6 h-6 border-2 rounded ${
todo.completed ? 'bg-blue-600 border-blue-600' : ''
}`}
>
{todo.completed && '✓'}
</button>
</form>
<span className={todo.completed ? 'line-through' : ''}>
{todo.text}
</span>
{/* Delete form */}
<form action={deleteTodo.bind(null, todo.id)} className="ml-auto">
<button
type="submit"
className="text-red-600 hover:text-red-800"
>
Delete
</button>
</form>
</div>
))}
</div>
</div>
);
}
// ✅ Full CRUD without API routes
// ✅ Works without JavaScript
// ✅ Automatic data revalidationExample 2: Contact Form
'use server';
export async function submitContactForm(formData: FormData) {
const name = formData.get('name') as string;
const email = formData.get('email') as string;
const message = formData.get('message') as string;
// Validate
if (!name || !email || !message) {
return {
success: false,
error: 'All fields are required',
};
}
// Save to database
await db.contacts.create({
data: { name, email, message },
});
// Send email notification
await sendEmail({
to: 'support@example.com',
subject: 'New Contact Form Submission',
body: `Name: ${name}
Email: ${email}
Message: ${message}`,
});
return {
success: true,
message: 'Thank you! We will get back to you soon.',
};
}
// ✅ Complete form processing
// ✅ Validation
// ✅ Database + email
// ✅ Success/error responses'use client';
import { submitContactForm } from '@/app/actions/contact';
import { useState } from 'react';
export default function ContactPage() {
const [result, setResult] = useState<{ success: boolean; message?: string; error?: string } | null>(null);
async function handleSubmit(formData: FormData) {
const response = await submitContactForm(formData);
setResult(response);
}
return (
<div className="max-w-2xl mx-auto p-8">
<h1 className="text-3xl font-bold mb-8">Contact Us</h1>
<form action={handleSubmit} className="space-y-4">
<div>
<label className="block font-semibold mb-2">Name</label>
<input
name="name"
required
className="w-full px-4 py-2 border rounded"
/>
</div>
<div>
<label className="block font-semibold mb-2">Email</label>
<input
name="email"
type="email"
required
className="w-full px-4 py-2 border rounded"
/>
</div>
<div>
<label className="block font-semibold mb-2">Message</label>
<textarea
name="message"
required
rows={5}
className="w-full px-4 py-2 border rounded"
/>
</div>
<button
type="submit"
className="px-6 py-3 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Send Message
</button>
{result && (
<div
className={`p-4 rounded ${
result.success ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}
>
{result.success ? result.message : result.error}
</div>
)}
</form>
</div>
);
}
// ✅ Client Component for result display
// ✅ Server Action for submission
// ✅ Success/error feedbackServer Actions Project Structure
Organization of Server Actions in Next.js
Select a file or folder to see details
Server Actions Best Practices
1. Organize Actions by Domain
// ✅ GOOD: Organized by feature
app/
├── actions/
│ ├── posts.ts # Post actions
│ ├── users.ts # User actions
│ ├── comments.ts # Comment actions
│ └── auth.ts # Auth actions
// ❌ BAD: Single actions file
app/
└── actions.ts # Everything in one file2. Return Structured Responses
// ✅ GOOD: Structured response
export async function createPost(formData: FormData) {
try {
// ...
return { success: true, postId: post.id, message: 'Created!' };
} catch (error) {
return { success: false, error: 'Failed to create' };
}
}
// ❌ BAD: Throwing errors
export async function createPost(formData: FormData) {
// ...
throw new Error('Failed'); // Hard to handle in UI
}3. Use revalidatePath After Mutations
// ✅ GOOD: Revalidate after mutation
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
await db.posts.create({ ... });
revalidatePath('/blog'); // Refresh cached data
}
// ❌ BAD: No revalidation
export async function createPost(formData: FormData) {
await db.posts.create({ ... });
// Cache not updated - users see stale data
}4. Add Server-Side Validation
// ✅ GOOD: Validate on server
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
if (!title || title.length < 3) {
return { success: false, error: 'Title must be at least 3 characters' };
}
// Continue...
}
// Never trust client-side validation alone5. Use TypeScript for Type Safety
// ✅ GOOD: Type-safe
interface CreatePostResult {
success: boolean;
postId?: string;
error?: string;
}
export async function createPost(formData: FormData): Promise<CreatePostResult> {
// Implementation
}
// Full type safety from server to clientKey Takeaways
- 'use server' - marks functions as Server Actions
- Run on server - execute server-side automatically
- Type-safe - full TypeScript support
- Progressive enhancement - works without JavaScript
- Co-located - define near components
- FormData or arguments - flexible parameter passing
- Return data - structured responses
- Revalidation - update cached data after mutations
What's Next?
You've mastered Server Actions basics! Next, we'll explore Form Handling with Server Actions—building complete forms with progressive enhancement, handling different input types, file uploads, and creating accessible forms that work without JavaScript. You'll build production-ready forms!
We'll cover form structure, input types, accessibility, progressive enhancement patterns, and complete form examples with Server Actions.
🚀 Server Actions vs API Routes
Server Actions are the recommended way for mutations in Next.js 15. Use them instead of API routes for forms and data mutations. Reserve API routes for third-party webhooks, REST APIs for external clients, or when you need full control over HTTP methods and headers.