Next.js Server Actions
Overview
Server Actions are asynchronous functions that execute on the server. They can be called from Client and Server Components for data mutations, form submissions, and other server-side operations.
Defining Server Actions
In Server Components
Use the 'use server' directive inside an async function:
// app/page.tsx (Server Component) export default function Page() { async function createPost(formData: FormData) { 'use server' const title = formData.get('title') as string await db.post.create({ data: { title } }) } return ( <form action={createPost}> <input name="title" /> <button type="submit">Create</button> </form> ) }
In Separate Files
Mark the entire file with 'use server':
// app/actions.ts 'use server' export async function createPost(formData: FormData) { const title = formData.get('title') as string await db.post.create({ data: { title } }) } export async function deletePost(id: string) { await db.post.delete({ where: { id } }) }
Form Handling
Basic Form
// app/actions.ts 'use server' export async function submitContact(formData: FormData) { const name = formData.get('name') as string const email = formData.get('email') as string const message = formData.get('message') as string await db.contact.create({ data: { name, email, message } }) } // app/contact/page.tsx import { submitContact } from '@/app/actions' export default function ContactPage() { return ( <form action={submitContact}> <input name="name" required /> <input name="email" type="email" required /> <textarea name="message" required /> <button type="submit">Send</button> </form> ) }
With Validation (Zod)
// app/actions.ts 'use server' import { z } from 'zod' const schema = z.object({ email: z.string().email(), password: z.string().min(8), }) export async function signup(formData: FormData) { const parsed = schema.safeParse({ email: formData.get('email'), password: formData.get('password'), }) if (!parsed.success) { return { error: parsed.error.flatten() } } await createUser(parsed.data) return { success: true } }
useFormState Hook
Handle form state and errors:
// app/signup/page.tsx 'use client' import { useFormState } from 'react-dom' import { signup } from '@/app/actions' const initialState = { error: null, success: false, } export default function SignupPage() { const [state, formAction] = useFormState(signup, initialState) return ( <form action={formAction}> <input name="email" type="email" /> <input name="password" type="password" /> {state.error && ( <p className="text-red-500">{state.error}</p> )} <button type="submit">Sign Up</button> </form> ) } // app/actions.ts 'use server' export async function signup(prevState: any, formData: FormData) { const email = formData.get('email') as string if (!email.includes('@')) { return { error: 'Invalid email', success: false } } await createUser({ email }) return { error: null, success: true } }
useFormStatus Hook
Show loading states during submission:
// components/submit-button.tsx 'use client' import { useFormStatus } from 'react-dom' export function SubmitButton() { const { pending } = useFormStatus() return ( <button type="submit" disabled={pending}> {pending ? 'Submitting...' : 'Submit'} </button> ) } // Usage in form import { SubmitButton } from '@/components/submit-button' export default function Form() { return ( <form action={submitAction}> <input name="title" /> <SubmitButton /> </form> ) }
Revalidation
revalidatePath
Revalidate a specific path:
'use server' import { revalidatePath } from 'next/cache' export async function createPost(formData: FormData) { await db.post.create({ data: { ... } }) // Revalidate the posts list page revalidatePath('/posts') // Revalidate a dynamic route revalidatePath('/posts/[slug]', 'page') // Revalidate all paths under /posts revalidatePath('/posts', 'layout') }
revalidateTag
Revalidate by cache tag:
// Fetching with tags const posts = await fetch('https://api.example.com/posts', { next: { tags: ['posts'] } }) // Server Action 'use server' import { revalidateTag } from 'next/cache' export async function createPost(formData: FormData) { await db.post.create({ data: { ... } }) revalidateTag('posts') }
Redirects After Actions
'use server' import { redirect } from 'next/navigation' export async function createPost(formData: FormData) { const post = await db.post.create({ data: { ... } }) // Redirect to the new post redirect(`/posts/${post.slug}`) }
Optimistic Updates
Update UI immediately while action completes:
'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: 'temp', title: newTodo, completed: false } ] ) async function handleSubmit(formData: FormData) { const title = formData.get('title') as string addOptimisticTodo(title) // Update UI immediately await addTodo(formData) // Server action } return ( <> <form action={handleSubmit}> <input name="title" /> <button>Add</button> </form> <ul> {optimisticTodos.map(todo => ( <li key={todo.id}>{todo.title}</li> ))} </ul> </> ) }
Non-Form Usage
Call Server Actions programmatically:
'use client' import { deletePost } from '@/app/actions' export function DeleteButton({ id }: { id: string }) { return ( <button onClick={() => deletePost(id)}> Delete </button> ) }
Error Handling
'use server' export async function createPost(formData: FormData) { try { await db.post.create({ data: { ... } }) return { success: true } } catch (error) { if (error instanceof PrismaClientKnownRequestError) { if (error.code === 'P2002') { return { error: 'A post with this title already exists' } } } return { error: 'Failed to create post' } } }
Security Considerations
- Always validate input - Never trust client data
- Check authentication - Verify user is authorized
- Use CSRF protection - Built-in with Server Actions
- Sanitize output - Prevent XSS attacks
'use server' import { auth } from '@/lib/auth' export async function deletePost(id: string) { const session = await auth() if (!session) { throw new Error('Unauthorized') } const post = await db.post.findUnique({ where: { id } }) if (post.authorId !== session.user.id) { throw new Error('Forbidden') } await db.post.delete({ where: { id } }) }
Resources
For detailed patterns, see:
references/form-handling.md- Advanced form patternsreferences/revalidation.md- Cache revalidation strategiesexamples/mutation-patterns.md- Complete mutation examples