Main Site ↗

server-actions

by davepoon2.7k243GitHub

Provides detailed guidance on implementing Next.js Server Actions for form submissions and data mutations. Covers form state management, validation with Zod, revalidation patterns, and real-world examples like shopping carts and auto-save. Includes code snippets for common use cases.

Unlock Deep Analysis

Use AI to visualize the workflow and generate a realistic output preview for this skill.

Powered by Fastest LLM

Target Audience

Next.js developers working with App Router who need to implement form submissions, data mutations, or real-time updates without external APIs

10/10Security

Low security risk, safe to use

9
Clarity
9
Practicality
8
Quality
8
Maintainability
7
Innovation
Frontend
nextjsserver-actionsform-handlingdata-mutationapp-router
Compatible Agents
Claude Code
Claude Code
~/.claude/skills/
Codex CLI
Codex CLI
~/.codex/skills/
Gemini CLI
Gemini CLI
~/.gemini/skills/
O
OpenCode
~/.opencode/skills/
O
OpenClaw
~/.openclaw/skills/
GitHub Copilot
GitHub Copilot
~/.copilot/skills/
Cursor
Cursor
~/.cursor/skills/
W
Windsurf
~/.codeium/windsurf/skills/
C
Cline
~/.cline/skills/
R
Roo Code
~/.roo/skills/
K
Kiro
~/.kiro/skills/
J
Junie
~/.junie/skills/
A
Augment Code
~/.augment/skills/
W
Warp
~/.warp/skills/
G
Goose
~/.config/goose/skills/
SKILL.md

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

  1. Always validate input - Never trust client data
  2. Check authentication - Verify user is authorized
  3. Use CSRF protection - Built-in with Server Actions
  4. 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 patterns
  • references/revalidation.md - Cache revalidation strategies
  • examples/mutation-patterns.md - Complete mutation examples

Referenced Files

The following files are referenced in this skill and included for context.

references/form-handling.md

# Form Handling with Server Actions

## Basic Form Setup

### Server Action in Separate File

```tsx
// actions/contact.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 },
  })
}

Form Component

// app/contact/page.tsx
import { submitContact } from '@/actions/contact'

export default function ContactPage() {
  return (
    <form action={submitContact}>
      <input name="name" placeholder="Name" required />
      <input name="email" type="email" placeholder="Email" required />
      <textarea name="message" placeholder="Message" required />
      <button type="submit">Send</button>
    </form>
  )
}

useFormState for Feedback

Action with Return Value

// actions/subscribe.ts
'use server'

import { z } from 'zod'

const schema = z.object({
  email: z.string().email('Invalid email address'),
})

export type SubscribeState = {
  success?: boolean
  error?: string
}

export async function subscribe(
  prevState: SubscribeState,
  formData: FormData
): Promise<SubscribeState> {
  const email = formData.get('email')

  const validated = schema.safeParse({ email })

  if (!validated.success) {
    return { error: validated.error.errors[0].message }
  }

  try {
    await db.subscriber.create({
      data: { email: validated.data.email },
    })
    return { success: true }
  } catch (error) {
    return { error: 'Failed to subscribe' }
  }
}

Form with useFormState

// components/subscribe-form.tsx
'use client'

import { useFormState } from 'react-dom'
import { subscribe, type SubscribeState } from '@/actions/subscribe'

const initialState: SubscribeState = {}

export function SubscribeForm() {
  const [state, formAction] = useFormState(subscribe, initialState)

  return (
    <form action={formAction}>
      <input
        name="email"
        type="email"
        placeholder="Enter your email"
        required
      />

      <SubmitButton />

      {state.error && (
        <p className="text-red-500">{state.error}</p>
      )}

      {state.success && (
        <p className="text-green-500">Successfully subscribed!</p>
      )}
    </form>
  )
}

useFormStatus for Loading States

// components/submit-button.tsx
'use client'

import { useFormStatus } from 'react-dom'

export function SubmitButton() {
  const { pending } = useFormStatus()

  return (
    <button
      type="submit"
      disabled={pending}
      className={pending ? 'opacity-50' : ''}
    >
      {pending ? 'Submitting...' : 'Submit'}
    </button>
  )
}

With Additional Status Info

// components/form-status.tsx
'use client'

import { useFormStatus } from 'react-dom'

export function FormStatus() {
  const { pending, data, method, action } = useFormStatus()

  if (!pending) return null

  return (
    <div className="text-gray-500">
      <p>Submitting form...</p>
      {data && <p>Fields: {Array.from(data.keys()).join(', ')}</p>}
    </div>
  )
}

Complex Form with Multiple Fields

Action

// actions/create-post.ts
'use server'

import { z } from 'zod'
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'

const createPostSchema = z.object({
  title: z.string().min(1, 'Title is required').max(200),
  content: z.string().min(10, 'Content must be at least 10 characters'),
  category: z.enum(['tech', 'lifestyle', 'business']),
  published: z.coerce.boolean().optional().default(false),
  tags: z.string().transform(str =>
    str.split(',').map(tag => tag.trim()).filter(Boolean)
  ),
})

export type CreatePostState = {
  success?: boolean
  errors?: {
    title?: string[]
    content?: string[]
    category?: string[]
    _form?: string[]
  }
}

export async function createPost(
  prevState: CreatePostState,
  formData: FormData
): Promise<CreatePostState> {
  const rawData = {
    title: formData.get('title'),
    content: formData.get('content'),
    category: formData.get('category'),
    published: formData.get('published'),
    tags: formData.get('tags'),
  }

  const validated = createPostSchema.safeParse(rawData)

  if (!validated.success) {
    return {
      errors: validated.error.flatten().fieldErrors,
    }
  }

  try {
    const post = await db.post.create({
      data: {
        ...validated.data,
        authorId: getCurrentUserId(),
      },
    })

    revalidatePath('/posts')
    redirect(`/posts/${post.id}`)
  } catch (error) {
    return {
      errors: {
        _form: ['Failed to create post. Please try again.'],
      },
    }
  }
}

Form Component

// components/create-post-form.tsx
'use client'

import { useFormState } from 'react-dom'
import { createPost, type CreatePostState } from '@/actions/create-post'

const initialState: CreatePostState = {}

export function CreatePostForm() {
  const [state, formAction] = useFormState(createPost, initialState)

  return (
    <form action={formAction} className="space-y-4">
      {/* Form-level errors */}
      {state.errors?._form && (
        <div className="bg-red-100 text-red-700 p-4 rounded">
          {state.errors._form.map((error, i) => (
            <p key={i}>{error}</p>
          ))}
        </div>
      )}

      {/* Title field */}
      <div>
        <label htmlFor="title">Title</label>
        <input
          id="title"
          name="title"
          className={state.errors?.title ? 'border-red-500' : ''}
        />
        {state.errors?.title && (
          <p className="text-red-500 text-sm">{state.errors.title[0]}</p>
        )}
      </div>

      {/* Content field */}
      <div>
        <label htmlFor="content">Content</label>
        <textarea
          id="content"
          name="content"
          rows={10}
          className={state.errors?.content ? 'border-red-500' : ''}
        />
        {state.errors?.content && (
          <p className="text-red-500 text-sm">{state.errors.content[0]}</p>
        )}
      </div>

      {/* Category select */}
      <div>
        <label htmlFor="category">Category</label>
        <select id="category" name="category">
          <option value="">Select category</option>
          <option value="tech">Technology</option>
          <option value="lifestyle">Lifestyle</option>
          <option value="business">Business</option>
        </select>
        {state.errors?.category && (
          <p className="text-red-500 text-sm">{state.errors.category[0]}</p>
        )}
      </div>

      {/* Published checkbox */}
      <div className="flex items-center gap-2">
        <input type="checkbox" id="published" name="published" value="true" />
        <label htmlFor="published">Publish immediately</label>
      </div>

      {/* Tags input */}
      <div>
        <label htmlFor="tags">Tags (comma-separated)</label>
        <input id="tags" name="tags" placeholder="react, nextjs, typescript" />
      </div>

      <SubmitButton />
    </form>
  )
}

File Upload Form

// actions/upload.ts
'use server'

import { writeFile } from 'fs/promises'
import path from 'path'

export type UploadState = {
  success?: boolean
  error?: string
  url?: string
}

export async function uploadFile(
  prevState: UploadState,
  formData: FormData
): Promise<UploadState> {
  const file = formData.get('file') as File

  if (!file || file.size === 0) {
    return { error: 'No file selected' }
  }

  // Validate file type
  const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']
  if (!allowedTypes.includes(file.type)) {
    return { error: 'Invalid file type. Only JPEG, PNG, and WebP allowed.' }
  }

  // Validate file size (5MB)
  if (file.size > 5 * 1024 * 1024) {
    return { error: 'File too large. Maximum 5MB allowed.' }
  }

  try {
    const bytes = await file.arrayBuffer()
    const buffer = Buffer.from(bytes)

    const filename = `${Date.now()}-${file.name}`
    const filepath = path.join(process.cwd(), 'public/uploads', filename)

    await writeFile(filepath, buffer)

    return {
      success: true,
      url: `/uploads/${filename}`,
    }
  } catch (error) {
    return { error: 'Failed to upload file' }
  }
}

Upload Form Component

// components/upload-form.tsx
'use client'

import { useFormState } from 'react-dom'
import { useState } from 'react'
import { uploadFile, type UploadState } from '@/actions/upload'

const initialState: UploadState = {}

export function UploadForm() {
  const [state, formAction] = useFormState(uploadFile, initialState)
  const [preview, setPreview] = useState<string | null>(null)

  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0]
    if (file) {
      setPreview(URL.createObjectURL(file))
    }
  }

  return (
    <form action={formAction} className="space-y-4">
      <div>
        <label htmlFor="file" className="block">
          Choose an image
        </label>
        <input
          type="file"
          id="file"
          name="file"
          accept="image/jpeg,image/png,image/webp"
          onChange={handleFileChange}
        />
      </div>

      {preview && (
        <div>
          <img src={preview} alt="Preview" className="max-w-xs rounded" />
        </div>
      )}

      <SubmitButton />

      {state.error && (
        <p className="text-red-500">{state.error}</p>
      )}

      {state.success && state.url && (
        <div className="text-green-500">
          <p>Upload successful!</p>
          <img src={state.url} alt="Uploaded" className="max-w-xs mt-2" />
        </div>
      )}
    </form>
  )
}

Form with Dynamic Fields

// components/dynamic-form.tsx
'use client'

import { useFormState } from 'react-dom'
import { useState } from 'react'
import { submitItems } from '@/actions/items'

export function DynamicForm() {
  const [state, formAction] = useFormState(submitItems, {})
  const [items, setItems] = useState([{ id: 1, value: '' }])

  const addItem = () => {
    setItems(prev => [...prev, { id: Date.now(), value: '' }])
  }

  const removeItem = (id: number) => {
    setItems(prev => prev.filter(item => item.id !== id))
  }

  return (
    <form action={formAction}>
      {items.map((item, index) => (
        <div key={item.id} className="flex gap-2">
          <input
            name={`items[${index}]`}
            placeholder={`Item ${index + 1}`}
          />
          <button
            type="button"
            onClick={() => removeItem(item.id)}
          >
            Remove
          </button>
        </div>
      ))}

      <button type="button" onClick={addItem}>
        Add Item
      </button>

      <SubmitButton />
    </form>
  )
}

Process Dynamic Fields

// actions/items.ts
'use server'

export async function submitItems(
  prevState: unknown,
  formData: FormData
) {
  // Get all items from form
  const items: string[] = []
  let index = 0

  while (formData.has(`items[${index}]`)) {
    const value = formData.get(`items[${index}]`) as string
    if (value.trim()) {
      items.push(value.trim())
    }
    index++
  }

  // Process items...
  await db.item.createMany({
    data: items.map(name => ({ name })),
  })

  return { success: true, count: items.length }
}

Progressive Enhancement

Forms work without JavaScript enabled:

// components/search-form.tsx
import { searchPosts } from '@/actions/search'

export function SearchForm() {
  return (
    <form action={searchPosts}>
      <input name="q" placeholder="Search..." />
      <button type="submit">Search</button>
    </form>
  )
}
// actions/search.ts
'use server'

import { redirect } from 'next/navigation'

export async function searchPosts(formData: FormData) {
  const query = formData.get('q') as string
  redirect(`/search?q=${encodeURIComponent(query)}`)
}

### references/revalidation.md

```markdown
# Revalidation with Server Actions

## Overview

After mutations, revalidate cached data to reflect changes:
- `revalidatePath()` - Invalidate specific routes
- `revalidateTag()` - Invalidate by cache tag

## revalidatePath

### Basic Usage

```tsx
// actions/posts.ts
'use server'

import { revalidatePath } from 'next/cache'

export async function createPost(formData: FormData) {
  const post = await db.post.create({
    data: {
      title: formData.get('title') as string,
      content: formData.get('content') as string,
    },
  })

  // Revalidate the posts list page
  revalidatePath('/posts')
}

Path Types

// Revalidate a specific page
revalidatePath('/posts')

// Revalidate a dynamic route
revalidatePath('/posts/[slug]', 'page')

// Revalidate a layout (and all pages using it)
revalidatePath('/posts', 'layout')

// Revalidate everything
revalidatePath('/', 'layout')

Revalidate Multiple Paths

// actions/update-post.ts
'use server'

import { revalidatePath } from 'next/cache'

export async function updatePost(id: string, formData: FormData) {
  const post = await db.post.update({
    where: { id },
    data: {
      title: formData.get('title') as string,
      content: formData.get('content') as string,
    },
  })

  // Revalidate both the list and detail pages
  revalidatePath('/posts')
  revalidatePath(`/posts/${post.slug}`)
}

With Dynamic Segments

// actions/category.ts
'use server'

import { revalidatePath } from 'next/cache'

export async function updateCategory(categorySlug: string, formData: FormData) {
  await db.category.update({
    where: { slug: categorySlug },
    data: { name: formData.get('name') as string },
  })

  // Revalidate the specific category page
  revalidatePath(`/categories/${categorySlug}`)

  // Also revalidate all product pages in this category
  revalidatePath('/products/[...slug]', 'page')
}

revalidateTag

Setup Tags in Data Fetching

// lib/data.ts
export async function getPosts() {
  const res = await fetch('https://api.example.com/posts', {
    next: { tags: ['posts'] },
  })
  return res.json()
}

export async function getPost(id: string) {
  const res = await fetch(`https://api.example.com/posts/${id}`, {
    next: { tags: ['posts', `post-${id}`] },
  })
  return res.json()
}

export async function getUser(id: string) {
  const res = await fetch(`https://api.example.com/users/${id}`, {
    next: { tags: ['users', `user-${id}`] },
  })
  return res.json()
}

Revalidate by Tag

// actions/posts.ts
'use server'

import { revalidateTag } from 'next/cache'

export async function createPost(formData: FormData) {
  await db.post.create({
    data: {
      title: formData.get('title') as string,
    },
  })

  // Invalidate all data tagged with 'posts'
  revalidateTag('posts')
}

export async function updatePost(id: string, formData: FormData) {
  await db.post.update({
    where: { id },
    data: { title: formData.get('title') as string },
  })

  // Invalidate just this specific post
  revalidateTag(`post-${id}`)
}

export async function deletePost(id: string) {
  await db.post.delete({ where: { id } })

  // Invalidate both the specific post and the list
  revalidateTag(`post-${id}`)
  revalidateTag('posts')
}

Multiple Tags

// actions/user.ts
'use server'

import { revalidateTag } from 'next/cache'

export async function updateUserProfile(userId: string, formData: FormData) {
  await db.user.update({
    where: { id: userId },
    data: {
      name: formData.get('name') as string,
      bio: formData.get('bio') as string,
    },
  })

  // Revalidate user data
  revalidateTag(`user-${userId}`)

  // Also revalidate their posts (which show author info)
  revalidateTag(`posts-by-${userId}`)
}

Combining revalidatePath and revalidateTag

// actions/blog.ts
'use server'

import { revalidatePath, revalidateTag } from 'next/cache'

export async function publishPost(id: string) {
  const post = await db.post.update({
    where: { id },
    data: { published: true },
  })

  // Revalidate cached API data by tag
  revalidateTag('posts')
  revalidateTag(`post-${id}`)

  // Revalidate rendered pages by path
  revalidatePath('/posts')
  revalidatePath(`/posts/${post.slug}`)
  revalidatePath('/') // Home page might show latest posts
}

Revalidation Patterns

Optimistic Update + Revalidation

// components/like-button.tsx
'use client'

import { useOptimistic, useTransition } from 'react'
import { likePost } from '@/actions/posts'

interface Props {
  postId: string
  initialLikes: number
  isLiked: boolean
}

export function LikeButton({ postId, initialLikes, isLiked }: Props) {
  const [isPending, startTransition] = useTransition()
  const [optimisticState, addOptimistic] = useOptimistic(
    { likes: initialLikes, isLiked },
    (state, _action) => ({
      likes: state.isLiked ? state.likes - 1 : state.likes + 1,
      isLiked: !state.isLiked,
    })
  )

  const handleLike = () => {
    startTransition(async () => {
      addOptimistic(null)
      await likePost(postId) // This will revalidate
    })
  }

  return (
    <button onClick={handleLike} disabled={isPending}>
      {optimisticState.isLiked ? '❤️' : '🤍'} {optimisticState.likes}
    </button>
  )
}
// actions/posts.ts
'use server'

import { revalidateTag } from 'next/cache'

export async function likePost(postId: string) {
  const userId = await getCurrentUserId()

  await db.like.upsert({
    where: {
      userId_postId: { userId, postId },
    },
    create: { userId, postId },
    update: {},
  })

  revalidateTag(`post-${postId}`)
}

Cascade Revalidation

// actions/comments.ts
'use server'

import { revalidateTag, revalidatePath } from 'next/cache'

export async function addComment(postId: string, formData: FormData) {
  const comment = await db.comment.create({
    data: {
      postId,
      content: formData.get('content') as string,
      authorId: await getCurrentUserId(),
    },
    include: { post: true },
  })

  // Revalidate the specific post (comment count changed)
  revalidateTag(`post-${postId}`)

  // Revalidate comments for this post
  revalidateTag(`comments-${postId}`)

  // Revalidate the post page
  revalidatePath(`/posts/${comment.post.slug}`)

  // Revalidate "recent comments" widgets
  revalidateTag('recent-comments')
}

Conditional Revalidation

// actions/posts.ts
'use server'

import { revalidatePath, revalidateTag } from 'next/cache'

export async function updatePost(id: string, formData: FormData) {
  const wasPublished = await db.post.findUnique({
    where: { id },
    select: { published: true },
  })

  const post = await db.post.update({
    where: { id },
    data: {
      title: formData.get('title') as string,
      published: formData.get('published') === 'true',
    },
  })

  // Always revalidate the specific post
  revalidateTag(`post-${id}`)
  revalidatePath(`/posts/${post.slug}`)

  // Only revalidate the list if publish status changed
  if (wasPublished?.published !== post.published) {
    revalidateTag('posts')
    revalidatePath('/posts')
    revalidatePath('/') // Feed page
  }
}

Route Handler Revalidation

// app/api/revalidate/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { revalidateTag, revalidatePath } from 'next/cache'

export async function POST(request: NextRequest) {
  const secret = request.headers.get('x-revalidate-secret')

  if (secret !== process.env.REVALIDATE_SECRET) {
    return NextResponse.json({ error: 'Invalid secret' }, { status: 401 })
  }

  const body = await request.json()

  if (body.tag) {
    revalidateTag(body.tag)
  }

  if (body.path) {
    revalidatePath(body.path)
  }

  return NextResponse.json({ revalidated: true, now: Date.now() })
}

On-Demand Revalidation from External Webhook

// app/api/webhook/cms/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { revalidateTag } from 'next/cache'

export async function POST(request: NextRequest) {
  const signature = request.headers.get('x-webhook-signature')
  const body = await request.json()

  // Verify webhook signature
  if (!verifyWebhookSignature(signature, body)) {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
  }

  const { event, data } = body

  switch (event) {
    case 'post.created':
    case 'post.updated':
    case 'post.deleted':
      revalidateTag('posts')
      revalidateTag(`post-${data.id}`)
      break

    case 'user.updated':
      revalidateTag(`user-${data.id}`)
      break

    case 'cache.purge':
      // Full cache purge
      revalidateTag('all')
      break
  }

  return NextResponse.json({ success: true })
}

Debugging Revalidation

// actions/debug.ts
'use server'

import { revalidatePath, revalidateTag } from 'next/cache'

export async function debugRevalidate(target: string, type: 'path' | 'tag') {
  console.log(`Revalidating ${type}: ${target}`)

  if (type === 'tag') {
    revalidateTag(target)
  } else {
    revalidatePath(target)
  }

  console.log(`Revalidation complete at: ${new Date().toISOString()}`)
}

### examples/mutation-patterns.md

```markdown
# Server Actions Mutation Patterns

## Basic CRUD Mutations

### Create

```tsx
// actions/posts.ts
'use server'

import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { z } from 'zod'
import { auth } from '@/auth'

const createPostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(10),
  published: z.boolean().optional().default(false),
})

export async function createPost(formData: FormData) {
  const session = await auth()
  if (!session?.user) {
    throw new Error('Unauthorized')
  }

  const validated = createPostSchema.parse({
    title: formData.get('title'),
    content: formData.get('content'),
    published: formData.get('published') === 'true',
  })

  const post = await db.post.create({
    data: {
      ...validated,
      authorId: session.user.id,
    },
  })

  revalidatePath('/posts')
  redirect(`/posts/${post.id}`)
}

Update

// actions/posts.ts
'use server'

export async function updatePost(id: string, formData: FormData) {
  const session = await auth()
  if (!session?.user) {
    throw new Error('Unauthorized')
  }

  // Verify ownership
  const post = await db.post.findUnique({
    where: { id },
    select: { authorId: true },
  })

  if (post?.authorId !== session.user.id) {
    throw new Error('Forbidden')
  }

  await db.post.update({
    where: { id },
    data: {
      title: formData.get('title') as string,
      content: formData.get('content') as string,
    },
  })

  revalidatePath('/posts')
  revalidatePath(`/posts/${id}`)
}

Delete

// actions/posts.ts
'use server'

export async function deletePost(id: string) {
  const session = await auth()
  if (!session?.user) {
    throw new Error('Unauthorized')
  }

  // Verify ownership or admin
  const post = await db.post.findUnique({
    where: { id },
    select: { authorId: true },
  })

  if (post?.authorId !== session.user.id && session.user.role !== 'admin') {
    throw new Error('Forbidden')
  }

  await db.post.delete({ where: { id } })

  revalidatePath('/posts')
  redirect('/posts')
}

Optimistic Updates

Like Button with Optimistic UI

// components/like-button.tsx
'use client'

import { useOptimistic, useTransition } from 'react'
import { toggleLike } from '@/actions/likes'

interface Props {
  postId: string
  initialLikes: number
  initialIsLiked: boolean
}

export function LikeButton({ postId, initialLikes, initialIsLiked }: Props) {
  const [isPending, startTransition] = useTransition()

  const [optimisticState, addOptimistic] = useOptimistic(
    { likes: initialLikes, isLiked: initialIsLiked },
    (state) => ({
      likes: state.isLiked ? state.likes - 1 : state.likes + 1,
      isLiked: !state.isLiked,
    })
  )

  async function handleClick() {
    startTransition(async () => {
      addOptimistic(null)
      await toggleLike(postId)
    })
  }

  return (
    <button
      onClick={handleClick}
      disabled={isPending}
      className="flex items-center gap-2"
    >
      <span className={optimisticState.isLiked ? 'text-red-500' : ''}>
        {optimisticState.isLiked ? '❤️' : '🤍'}
      </span>
      <span>{optimisticState.likes}</span>
    </button>
  )
}
// actions/likes.ts
'use server'

import { revalidateTag } from 'next/cache'
import { auth } from '@/auth'

export async function toggleLike(postId: string) {
  const session = await auth()
  if (!session?.user) {
    throw new Error('Unauthorized')
  }

  const existing = await db.like.findUnique({
    where: {
      userId_postId: {
        userId: session.user.id,
        postId,
      },
    },
  })

  if (existing) {
    await db.like.delete({
      where: { id: existing.id },
    })
  } else {
    await db.like.create({
      data: {
        userId: session.user.id,
        postId,
      },
    })
  }

  revalidateTag(`post-${postId}`)
}

Optimistic List Item

// components/todo-list.tsx
'use client'

import { useOptimistic, useRef } from 'react'
import { addTodo } from '@/actions/todos'

interface Todo {
  id: string
  text: string
  completed: boolean
}

export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
  const formRef = useRef<HTMLFormElement>(null)

  const [optimisticTodos, addOptimisticTodo] = useOptimistic(
    initialTodos,
    (state, newTodo: Todo) => [...state, newTodo]
  )

  async function handleSubmit(formData: FormData) {
    const text = formData.get('text') as string

    // Add optimistic todo with temporary ID
    addOptimisticTodo({
      id: `temp-${Date.now()}`,
      text,
      completed: false,
    })

    formRef.current?.reset()

    // Server action will revalidate
    await addTodo(formData)
  }

  return (
    <div>
      <ul>
        {optimisticTodos.map((todo) => (
          <li
            key={todo.id}
            className={todo.id.startsWith('temp-') ? 'opacity-50' : ''}
          >
            {todo.text}
          </li>
        ))}
      </ul>

      <form ref={formRef} action={handleSubmit}>
        <input name="text" placeholder="Add todo..." required />
        <button type="submit">Add</button>
      </form>
    </div>
  )
}

Error Handling

Returning Errors to UI

// actions/newsletter.ts
'use server'

import { z } from 'zod'

export type SubscribeResult = {
  success?: boolean
  error?: string
}

const schema = z.object({
  email: z.string().email('Please enter a valid email'),
})

export async function subscribe(
  _prevState: SubscribeResult,
  formData: FormData
): Promise<SubscribeResult> {
  const result = schema.safeParse({
    email: formData.get('email'),
  })

  if (!result.success) {
    return { error: result.error.errors[0].message }
  }

  try {
    // Check if already subscribed
    const existing = await db.subscriber.findUnique({
      where: { email: result.data.email },
    })

    if (existing) {
      return { error: 'This email is already subscribed' }
    }

    await db.subscriber.create({
      data: { email: result.data.email },
    })

    return { success: true }
  } catch (error) {
    console.error('Subscribe error:', error)
    return { error: 'Something went wrong. Please try again.' }
  }
}
// components/newsletter-form.tsx
'use client'

import { useFormState } from 'react-dom'
import { subscribe } from '@/actions/newsletter'
import { SubmitButton } from './submit-button'

export function NewsletterForm() {
  const [state, formAction] = useFormState(subscribe, {})

  if (state.success) {
    return (
      <div className="bg-green-100 text-green-800 p-4 rounded">
        Thanks for subscribing!
      </div>
    )
  }

  return (
    <form action={formAction} className="space-y-4">
      <div>
        <input
          name="email"
          type="email"
          placeholder="Enter your email"
          className={state.error ? 'border-red-500' : ''}
        />
        {state.error && (
          <p className="text-red-500 text-sm mt-1">{state.error}</p>
        )}
      </div>
      <SubmitButton>Subscribe</SubmitButton>
    </form>
  )
}

Global Error Boundary

// app/error.tsx
'use client'

import { useEffect } from 'react'

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  useEffect(() => {
    console.error('Application error:', error)
  }, [error])

  return (
    <div className="p-8 text-center">
      <h2 className="text-2xl font-bold text-red-600">Something went wrong!</h2>
      <p className="mt-2 text-gray-600">{error.message}</p>
      <button
        onClick={reset}
        className="mt-4 px-4 py-2 bg-blue-500 text-white rounded"
      >
        Try again
      </button>
    </div>
  )
}

Transaction Patterns

Multi-Step Transaction

// actions/checkout.ts
'use server'

import { revalidatePath } from 'next/cache'

export async function processCheckout(formData: FormData) {
  const session = await auth()
  if (!session?.user) {
    throw new Error('Unauthorized')
  }

  const cartItems = await db.cartItem.findMany({
    where: { userId: session.user.id },
    include: { product: true },
  })

  if (cartItems.length === 0) {
    return { error: 'Cart is empty' }
  }

  // Use transaction to ensure atomicity
  const order = await db.$transaction(async (tx) => {
    // 1. Create order
    const order = await tx.order.create({
      data: {
        userId: session.user.id,
        status: 'pending',
        total: cartItems.reduce(
          (sum, item) => sum + item.quantity * item.product.price,
          0
        ),
      },
    })

    // 2. Create order items
    await tx.orderItem.createMany({
      data: cartItems.map((item) => ({
        orderId: order.id,
        productId: item.productId,
        quantity: item.quantity,
        price: item.product.price,
      })),
    })

    // 3. Update inventory
    for (const item of cartItems) {
      await tx.product.update({
        where: { id: item.productId },
        data: {
          inventory: {
            decrement: item.quantity,
          },
        },
      })
    }

    // 4. Clear cart
    await tx.cartItem.deleteMany({
      where: { userId: session.user.id },
    })

    return order
  })

  revalidatePath('/cart')
  revalidatePath('/orders')

  return { success: true, orderId: order.id }
}

Real-time Updates with Server Actions

Polling Pattern

// components/live-data.tsx
'use client'

import { useEffect, useState, useTransition } from 'react'
import { getData } from '@/actions/data'

export function LiveData() {
  const [data, setData] = useState(null)
  const [isPending, startTransition] = useTransition()

  useEffect(() => {
    // Initial fetch
    startTransition(async () => {
      const result = await getData()
      setData(result)
    })

    // Poll every 5 seconds
    const interval = setInterval(() => {
      startTransition(async () => {
        const result = await getData()
        setData(result)
      })
    }, 5000)

    return () => clearInterval(interval)
  }, [])

  return (
    <div>
      {isPending && <span className="text-gray-400">Updating...</span>}
      <pre>{JSON.stringify(data, null, 2)}</pre>
    </div>
  )
}

Bound Actions with Arguments

// components/post-actions.tsx
import { deletePost, publishPost } from '@/actions/posts'

export function PostActions({ postId }: { postId: string }) {
  // Bind the postId to the action
  const deleteWithId = deletePost.bind(null, postId)
  const publishWithId = publishPost.bind(null, postId)

  return (
    <div className="flex gap-2">
      <form action={publishWithId}>
        <button type="submit">Publish</button>
      </form>

      <form action={deleteWithId}>
        <button type="submit" className="text-red-600">
          Delete
        </button>
      </form>
    </div>
  )
}
// actions/posts.ts
'use server'

export async function deletePost(postId: string) {
  await db.post.delete({ where: { id: postId } })
  revalidatePath('/posts')
}

export async function publishPost(postId: string) {
  await db.post.update({
    where: { id: postId },
    data: { published: true },
  })
  revalidatePath('/posts')
  revalidatePath(`/posts/${postId}`)
}

Debounced Auto-Save

// components/auto-save-form.tsx
'use client'

import { useEffect, useRef, useState, useTransition } from 'react'
import { saveDraft } from '@/actions/drafts'

export function AutoSaveForm({ draftId, initialContent }: {
  draftId: string
  initialContent: string
}) {
  const [content, setContent] = useState(initialContent)
  const [saved, setSaved] = useState(true)
  const [isPending, startTransition] = useTransition()
  const timeoutRef = useRef<NodeJS.Timeout>()

  useEffect(() => {
    if (content === initialContent) return

    setSaved(false)

    // Debounce save
    clearTimeout(timeoutRef.current)
    timeoutRef.current = setTimeout(() => {
      startTransition(async () => {
        await saveDraft(draftId, content)
        setSaved(true)
      })
    }, 1000)

    return () => clearTimeout(timeoutRef.current)
  }, [content, draftId, initialContent])

  return (
    <div>
      <div className="flex justify-between mb-2">
        <span>Draft</span>
        <span className="text-sm text-gray-500">
          {isPending ? 'Saving...' : saved ? 'Saved' : 'Unsaved changes'}
        </span>
      </div>
      <textarea
        value={content}
        onChange={(e) => setContent(e.target.value)}
        className="w-full h-64 p-4 border rounded"
      />
    </div>
  )
}
// actions/drafts.ts
'use server'

export async function saveDraft(id: string, content: string) {
  await db.draft.update({
    where: { id },
    data: {
      content,
      updatedAt: new Date(),
    },
  })
}

Source: https://github.com/davepoon/buildwithclaude#plugins~nextjs-expert~skills~server-actions

Content curated from original sources, copyright belongs to authors

Grade A
8.3AI Score
Best Practices
Checking...
Try this Skill

User Rating

USER RATING

0UP
0DOWN
Loading files...

WORKS WITH

Claude Code
Claude
Codex CLI
Codex
Gemini CLI
Gemini
O
OpenCode
O
OpenClaw
GitHub Copilot
Copilot
Cursor
Cursor
W
Windsurf
C
Cline
R
Roo
K
Kiro
J
Junie
A
Augment
W
Warp
G
Goose