Server Actions is a powerful feature introduced in Next.js 13.4+ that allows you to directly call server-side functions from server components, simplifying form submission and data mutation operations.
What are Server Actions?
Server Actions are asynchronous functions that execute on the server and can be called from client or server components. They provide a simple way to handle form submissions, data mutations, and other server-side operations.
Basic Syntax
javascript'use server'; export async function createTodo(formData) { const title = formData.get('title'); const description = formData.get('description'); await db.todo.create({ data: { title, description } }); revalidatePath('/todos'); }
Ways to Use Server Actions
1. In Forms
javascriptimport { createTodo } from './actions'; export default function TodoForm() { return ( <form action={createTodo}> <input name="title" placeholder="Title" required /> <textarea name="description" placeholder="Description" /> <button type="submit">Create Todo</button> </form> ); }
2. In Event Handlers
javascript'use client'; import { createTodo } from './actions'; export default function CreateTodoButton() { const [pending, startTransition] = useTransition(); const handleClick = () => { const formData = new FormData(); formData.append('title', 'New Todo'); formData.append('description', 'Description'); startTransition(async () => { await createTodo(formData); }); }; return ( <button onClick={handleClick} disabled={pending}> {pending ? 'Creating...' : 'Create Todo'} </button> ); }
3. Directly Call in Server Components
javascriptimport { createTodo } from './actions'; export default async function Page() { // Call directly in server component await createTodo(new FormData()); const todos = await db.todo.findMany(); return <TodoList todos={todos} />; }
Features of Server Actions
1. Automatic Form Data Handling
javascript'use server'; export async function updateUser(formData) { const name = formData.get('name'); const email = formData.get('email'); const avatar = formData.get('avatar'); // File object // Handle file upload if (avatar instanceof File) { const url = await uploadFile(avatar); await db.user.update({ where: { id: userId }, data: { name, email, avatar: url } }); } }
2. Return Data
javascript'use server'; export async function searchProducts(query) { const products = await db.product.findMany({ where: { name: { contains: query } } }); return { success: true, data: products, count: products.length }; } // Usage 'use client'; import { searchProducts } from './actions'; export default function SearchComponent() { const [results, setResults] = useState(null); const handleSearch = async (e) => { e.preventDefault(); const formData = new FormData(e.target); const query = formData.get('query'); const response = await searchProducts(query); setResults(response.data); }; return ( <form onSubmit={handleSearch}> <input name="query" /> <button type="submit">Search</button> {results && <ResultsList results={results} />} </form> ); }
3. Error Handling
javascript'use server'; export async function createPost(formData) { try { const title = formData.get('title'); const content = formData.get('content'); if (!title || !content) { return { error: 'Title and content are required' }; } const post = await db.post.create({ data: { title, content } }); return { success: true, post }; } catch (error) { return { error: 'Failed to create post' }; } } // Use error state 'use client'; import { useFormState } from 'react-dom'; import { createPost } from './actions'; export default function PostForm() { const [state, formAction] = useFormState(createPost, null); return ( <form action={formAction}> <input name="title" /> <textarea name="content" /> <button type="submit">Create Post</button> {state?.error && ( <div className="error">{state.error}</div> )} {state?.success && ( <div className="success">Post created!</div> )} </form> ); }
4. Redirect
javascript'use server'; import { redirect } from 'next/navigation'; export async function login(formData) { const email = formData.get('email'); const password = formData.get('password'); const user = await authenticate(email, password); if (user) { redirect('/dashboard'); } else { return { error: 'Invalid credentials' }; } }
5. Cache Revalidation
javascript'use server'; import { revalidatePath, revalidateTag } from 'next/cache'; export async function updatePost(postId, formData) { const title = formData.get('title'); const content = formData.get('content'); await db.post.update({ where: { id: postId }, data: { title, content } }); // Revalidate specific path revalidatePath('/posts'); revalidatePath(`/posts/${postId}`); // Or use tag revalidation revalidateTag('posts'); }
Advanced Usage
1. Server Actions with Parameters
javascript'use server'; export async function deletePost(postId: string) { await db.post.delete({ where: { id: postId } }); revalidatePath('/posts'); } // Usage 'use client'; import { deletePost } from './actions'; export default function PostCard({ post }) { const handleDelete = async () => { if (confirm('Are you sure?')) { await deletePost(post.id); } }; return ( <div> <h2>{post.title}</h2> <button onClick={handleDelete}>Delete</button> </div> ); }
2. Using bind to Bind Parameters
javascript'use server'; export async function updateTodo(todoId, formData) { const title = formData.get('title'); const completed = formData.get('completed') === 'true'; await db.todo.update({ where: { id: todoId }, data: { title, completed } }); revalidatePath('/todos'); } // Use bind 'use client'; import { updateTodo } from './actions'; export default function TodoItem({ todo }) { const updateTodoWithId = updateTodo.bind(null, todo.id); return ( <form action={updateTodoWithId}> <input name="title" defaultValue={todo.title} /> <input type="checkbox" name="completed" defaultChecked={todo.completed} value="true" /> <button type="submit">Update</button> </form> ); }
3. Optimistic Updates
javascript'use client'; import { useOptimistic } from 'react'; import { toggleTodo } from './actions'; export default function TodoList({ todos }) { const [optimisticTodos, addOptimisticTodo] = useOptimistic( todos, (state, newTodo) => { return state.map(todo => todo.id === newTodo.id ? { ...todo, completed: newTodo.completed } : todo ); } ); return ( <ul> {optimisticTodos.map(todo => ( <li key={todo.id}> <input type="checkbox" checked={todo.completed} onChange={async () => { addOptimisticTodo({ id: todo.id, completed: !todo.completed }); await toggleTodo(todo.id); }} /> {todo.title} </li> ))} </ul> ); }
4. File Upload
javascript'use server'; export async function uploadAvatar(formData) { const file = formData.get('avatar') as File; if (!file) { return { error: 'No file uploaded' }; } const bytes = await file.arrayBuffer(); const buffer = Buffer.from(bytes); // Upload to cloud storage const url = await uploadToCloudStorage(buffer, file.name); // Update user avatar await db.user.update({ where: { id: userId }, data: { avatar: url } }); revalidatePath('/profile'); return { success: true, url }; } // Usage 'use client'; export default function AvatarUpload() { return ( <form action={uploadAvatar}> <input type="file" name="avatar" accept="image/*" /> <button type="submit">Upload</button> </form> ); }
Real-world Use Cases
1. Blog Post Creation
javascript// app/actions/posts.ts 'use server'; import { revalidatePath } from 'next/navigation'; import { auth } from '@/auth'; export async function createPost(formData: FormData) { const session = await auth(); if (!session) { return { error: 'Unauthorized' }; } const title = formData.get('title') as string; const content = formData.get('content') as string; const tags = formData.get('tags') as string; const post = await db.post.create({ data: { title, content, authorId: session.user.id, tags: tags.split(',').map(tag => tag.trim()) } }); revalidatePath('/blog'); revalidatePath('/blog/new'); return { success: true, post }; } // app/blog/new/page.tsx import { createPost } from '@/app/actions/posts'; export default function NewPostPage() { return ( <div> <h1>Create New Post</h1> <form action={createPost}> <input name="title" placeholder="Title" required /> <textarea name="content" placeholder="Content" required /> <input name="tags" placeholder="Tags (comma separated)" /> <button type="submit">Publish</button> </form> </div> ); }
2. E-commerce Shopping Cart
javascript// app/actions/cart.ts 'use server'; import { revalidateTag } from 'next/cache'; import { auth } from '@/auth'; export async function addToCart(productId: string, quantity: number = 1) { const session = await auth(); if (!session) { return { error: 'Unauthorized' }; } await db.cartItem.upsert({ where: { userId_productId: { userId: session.user.id, productId } }, update: { quantity: { increment: quantity } }, create: { userId: session.user.id, productId, quantity } }); revalidateTag('cart'); return { success: true }; } export async function removeFromCart(itemId: string) { const session = await auth(); if (!session) { return { error: 'Unauthorized' }; } await db.cartItem.delete({ where: { id: itemId } }); revalidateTag('cart'); return { success: true }; } // app/components/AddToCartButton.tsx 'use client'; import { addToCart } from '@/app/actions/cart'; import { useTransition } from 'react'; export default function AddToCartButton({ productId }) { const [pending, startTransition] = useTransition(); const handleClick = () => { startTransition(async () => { await addToCart(productId); }); }; return ( <button onClick={handleClick} disabled={pending}> {pending ? 'Adding...' : 'Add to Cart'} </button> ); }
3. Comment System
javascript// app/actions/comments.ts 'use server'; import { revalidatePath } from 'next/navigation'; import { auth } from '@/auth'; export async function addComment(postId: string, formData: FormData) { const session = await auth(); if (!session) { return { error: 'Unauthorized' }; } const content = formData.get('content') as string; const comment = await db.comment.create({ data: { content, postId, authorId: session.user.id } }); revalidatePath(`/posts/${postId}`); return { success: true, comment }; } // app/posts/[id]/page.tsx import { addComment } from '@/app/actions/comments'; export default function PostPage({ params }) { const post = await getPost(params.id); return ( <div> <PostContent post={post} /> <CommentList comments={post.comments} /> <form action={addComment.bind(null, post.id)}> <textarea name="content" placeholder="Write a comment..." required /> <button type="submit">Post Comment</button> </form> </div> ); }
Best Practices
1. Validate Input
javascript'use server'; import { z } from 'zod'; const createPostSchema = z.object({ title: z.string().min(1).max(200), content: z.string().min(1), tags: z.array(z.string()).optional() }); export async function createPost(formData: FormData) { const validatedFields = createPostSchema.safeParse({ title: formData.get('title'), content: formData.get('content'), tags: formData.get('tags')?.split(',') }); if (!validatedFields.success) { return { error: 'Invalid input', details: validatedFields.error }; } // Handle validated data const { title, content, tags } = validatedFields.data; await db.post.create({ data: { title, content, tags } }); revalidatePath('/posts'); return { success: true }; }
2. Permission Checks
javascript'use server'; import { auth } from '@/auth'; export async function deleteUser(userId: string) { const session = await auth(); // Check if user is logged in if (!session) { return { error: 'Unauthorized' }; } // Check if user has permission if (session.user.role !== 'admin' && session.user.id !== userId) { return { error: 'Forbidden' }; } await db.user.delete({ where: { id: userId } }); revalidatePath('/users'); return { success: true }; }
3. Error Handling and Logging
javascript'use server'; import { revalidatePath } from 'next/navigation'; export async function sensitiveAction(formData: FormData) { try { // Perform operation await performAction(formData); revalidatePath('/'); return { success: true }; } catch (error) { // Log error console.error('Action failed:', error); // Return user-friendly error message return { error: 'Something went wrong. Please try again.' }; } }
4. Use TypeScript
javascript'use server'; import { z } from 'zod'; // Define input type const CreatePostInput = z.object({ title: z.string(), content: z.string() }); // Define return type type CreatePostResult = | { success: true; post: Post } | { success: false; error: string }; export async function createPost( formData: FormData ): Promise<CreatePostResult> { const input = CreatePostInput.parse({ title: formData.get('title'), content: formData.get('content') }); const post = await db.post.create({ data: input }); revalidatePath('/posts'); return { success: true, post }; }
Server Actions simplify server-side operations, making form handling and data mutations more intuitive and efficient. By properly using Server Actions, you can build cleaner and more maintainable Next.js applications.