Server Action Pattern

Build type-safe form submissions and data mutations using Next.js Server Actions with progressive enhancement and optimistic updates.

Overview#

Server Actions are async functions that run on the server, designed for handling form submissions and data mutations. They integrate seamlessly with React's form handling and provide progressive enhancement out of the box.

When to use:

  • Form submissions from client components
  • Data mutations that need revalidation
  • Actions requiring progressive enhancement (works without JavaScript)
  • Simple CRUD operations from the UI

Key features:

  • Automatic form handling with action attribute
  • Progressive enhancement (works without JS)
  • Built-in revalidation with revalidatePath and revalidateTag
  • Type-safe with TypeScript
  • Works with useActionState for loading states

Code Example#

Basic Server Action#

1// actions/posts.ts 2'use server' 3 4import { prisma } from '@/lib/db' 5import { auth } from '@/lib/auth' 6import { revalidatePath } from 'next/cache' 7import { redirect } from 'next/navigation' 8import { z } from 'zod' 9 10const CreatePostSchema = z.object({ 11 title: z.string().min(1, 'Title is required').max(200), 12 content: z.string().optional() 13}) 14 15export async function createPost(formData: FormData) { 16 const { userId } = await auth() 17 if (!userId) throw new Error('UNAUTHORIZED') 18 19 const validated = CreatePostSchema.parse({ 20 title: formData.get('title'), 21 content: formData.get('content') 22 }) 23 24 const post = await prisma.post.create({ 25 data: { ...validated, authorId: userId } 26 }) 27 28 revalidatePath('/posts') 29 redirect(`/posts/${post.id}`) 30}

Server Action with Return Value#

1// actions/posts.ts 2'use server' 3 4type ActionResult<T> = 5 | { success: true; data: T } 6 | { success: false; error: string } 7 8export async function updatePost( 9 postId: string, 10 formData: FormData 11): Promise<ActionResult<{ id: string }>> { 12 try { 13 const { userId } = await auth() 14 if (!userId) { 15 return { success: false, error: 'Not authenticated' } 16 } 17 18 const post = await prisma.post.findUnique({ where: { id: postId } }) 19 if (!post || post.authorId !== userId) { 20 return { success: false, error: 'Not authorized' } 21 } 22 23 const title = formData.get('title') as string 24 if (!title || title.length < 1) { 25 return { success: false, error: 'Title is required' } 26 } 27 28 const updated = await prisma.post.update({ 29 where: { id: postId }, 30 data: { title } 31 }) 32 33 revalidatePath(`/posts/${postId}`) 34 return { success: true, data: { id: updated.id } } 35 } catch (error) { 36 console.error('Update post error:', error) 37 return { success: false, error: 'Failed to update post' } 38 } 39}

Using with useActionState (React 19)#

1// components/create-post-form.tsx 2'use client' 3 4import { useActionState } from 'react' 5import { createPost } from '@/actions/posts' 6 7const initialState = { error: null as string | null } 8 9export function CreatePostForm() { 10 const [state, formAction, isPending] = useActionState( 11 async (prevState: typeof initialState, formData: FormData) => { 12 const result = await createPost(formData) 13 if (!result.success) { 14 return { error: result.error } 15 } 16 return { error: null } 17 }, 18 initialState 19 ) 20 21 return ( 22 <form action={formAction}> 23 {state.error && ( 24 <div className="text-red-500 mb-4">{state.error}</div> 25 )} 26 27 <input 28 name="title" 29 placeholder="Post title" 30 required 31 disabled={isPending} 32 className="w-full p-2 border rounded" 33 /> 34 35 <textarea 36 name="content" 37 placeholder="Content (optional)" 38 disabled={isPending} 39 className="w-full p-2 border rounded mt-2" 40 /> 41 42 <button 43 type="submit" 44 disabled={isPending} 45 className="mt-4 px-4 py-2 bg-blue-500 text-white rounded disabled:opacity-50" 46 > 47 {isPending ? 'Creating...' : 'Create Post'} 48 </button> 49 </form> 50 ) 51}

Delete Action with Confirmation#

1// actions/posts.ts 2'use server' 3 4export async function deletePost(postId: string): Promise<ActionResult<null>> { 5 const { userId } = await auth() 6 if (!userId) { 7 return { success: false, error: 'Not authenticated' } 8 } 9 10 const post = await prisma.post.findUnique({ where: { id: postId } }) 11 if (!post || post.authorId !== userId) { 12 return { success: false, error: 'Not authorized' } 13 } 14 15 await prisma.post.delete({ where: { id: postId } }) 16 17 revalidatePath('/posts') 18 return { success: true, data: null } 19} 20 21// components/delete-button.tsx 22'use client' 23 24import { deletePost } from '@/actions/posts' 25import { useTransition } from 'react' 26import { useRouter } from 'next/navigation' 27 28export function DeleteButton({ postId }: { postId: string }) { 29 const [isPending, startTransition] = useTransition() 30 const router = useRouter() 31 32 const handleDelete = () => { 33 if (!confirm('Are you sure you want to delete this post?')) return 34 35 startTransition(async () => { 36 const result = await deletePost(postId) 37 if (result.success) { 38 router.push('/posts') 39 } else { 40 alert(result.error) 41 } 42 }) 43 } 44 45 return ( 46 <button 47 onClick={handleDelete} 48 disabled={isPending} 49 className="text-red-500 disabled:opacity-50" 50 > 51 {isPending ? 'Deleting...' : 'Delete'} 52 </button> 53 ) 54}

Optimistic Updates#

1// components/like-button.tsx 2'use client' 3 4import { useOptimistic, useTransition } from 'react' 5import { toggleLike } from '@/actions/posts' 6 7export function LikeButton({ postId, initialLiked, initialCount }: { 8 postId: string 9 initialLiked: boolean 10 initialCount: number 11}) { 12 const [isPending, startTransition] = useTransition() 13 const [optimistic, setOptimistic] = useOptimistic( 14 { liked: initialLiked, count: initialCount }, 15 (state, newLiked: boolean) => ({ 16 liked: newLiked, 17 count: newLiked ? state.count + 1 : state.count - 1 18 }) 19 ) 20 21 const handleClick = () => { 22 startTransition(async () => { 23 setOptimistic(!optimistic.liked) 24 await toggleLike(postId) 25 }) 26 } 27 28 return ( 29 <button onClick={handleClick} disabled={isPending}> 30 {optimistic.liked ? 'Liked' : 'Like'} ({optimistic.count}) 31 </button> 32 ) 33}

Usage Instructions#

  1. Create action file: Add 'use server' directive at the top of the file
  2. Define action function: Create async function that accepts FormData or other arguments
  3. Validate input: Use Zod to validate form data
  4. Handle authentication: Check user is authenticated before mutations
  5. Revalidate data: Call revalidatePath() or revalidateTag() after mutations
  6. Return results: Return success/error objects for client handling

Best Practices#

  1. Use the 'use server' directive - Required at the top of server action files
  2. Return structured results - Use { success, data, error } pattern for predictable handling
  3. Validate all input - Never trust form data, always validate server-side
  4. Handle errors gracefully - Catch exceptions and return user-friendly messages
  5. Revalidate affected paths - Update cached data after mutations
  6. Use optimistic updates - Improve perceived performance with useOptimistic
  7. Keep actions focused - One action per mutation type

When to Use Server Actions vs Route Handlers#

Use Server Actions for:

  • Form submissions
  • Mutations from client components
  • Actions that need revalidation
  • Progressive enhancement (works without JS)

Use Route Handlers for:

  • Webhooks from external services
  • Public APIs consumed by other apps
  • Long-running operations
  • File uploads/downloads