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
actionattribute - Progressive enhancement (works without JS)
- Built-in revalidation with
revalidatePathandrevalidateTag - Type-safe with TypeScript
- Works with
useActionStatefor 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#
- Create action file: Add
'use server'directive at the top of the file - Define action function: Create async function that accepts
FormDataor other arguments - Validate input: Use Zod to validate form data
- Handle authentication: Check user is authenticated before mutations
- Revalidate data: Call
revalidatePath()orrevalidateTag()after mutations - Return results: Return success/error objects for client handling
Best Practices#
- Use the
'use server'directive - Required at the top of server action files - Return structured results - Use
{ success, data, error }pattern for predictable handling - Validate all input - Never trust form data, always validate server-side
- Handle errors gracefully - Catch exceptions and return user-friendly messages
- Revalidate affected paths - Update cached data after mutations
- Use optimistic updates - Improve perceived performance with
useOptimistic - 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
Related Patterns#
- Route Handler - For REST APIs and webhooks
- Error Handling - Consistent error responses
- Validation - Input validation with Zod
- Forms - Form components with react-hook-form