404 Not Found

404 Not Found


nginx

Server Actions Advanced

2. A True Story of a Front-End Engineer

(1) Pain Point: File uploads take 8 seconds, leading users to think the page has frozen

Diana is developing the "Attachment Upload" feature for TaskFlow. After a user selects a 5 MB image:

To make matters worse, there is no UI feedback during those 6 seconds—users think the page has frozen and will keep clicking the submit button.

(2) The Server Actions Solution

Use useOptimistic to immediately display "virtual" attachments in the UI + unified upload processing via Server Action + try-catch error handling.

TSX
// app/tasks/[id]/Attachments.tsx — Optimistic Update + File Upload
'use client'
import { useOptimistic, useActionState } from 'react'
import { uploadAttachment } from './actions'

export function Attachments({ taskId, initialFiles }: Props) {
  const [optimisticFiles, addOptimistic] = useOptimistic(
    initialFiles,
    (state, newFile: File) => [...state, { id: 'pending', name: newFile.name, url: URL.createObjectURL(newFile), status: 'uploading' }]
  )

  const [error, formAction, pending] = useActionState(uploadAttachment, null)

  return (
    <form action={formAction}>
      <input type="hidden" name="taskId" value={taskId} />
      <input type="file" name="file" onChange={e => {
        const file = e.target.files?.[0]
        if (file) addOptimistic(file)  // Display immediately on UI
      }} />
      <button type="submit" disabled={pending}>Upload</button>
      {error && <p style={{ color: 'red' }}>{error}</p>}
      <ul>{optimisticFiles.map(f => <li key={f.id}>{f.name} {f.status === 'uploading' ? '⏳' : '✅'}</li>)}</ul>
    </form>
  )
}

(3) Revenue

Dimension Traditional API Route Server Action
Perceived Latency 6 seconds (no feedback) Instant (optimistic update)
Number of lines of code 120 lines (API + client + validation) 45 lines
Error Handling Requires manual try-catch throughout the entire chain Centralized useActionState
File Size Verification Client + Server Separately Consolidated in Server Action

3. Three-Mode Error Handling

TSX
// app/error-demo/use-action-state.tsx
'use client'
import { useActionState } from 'react'

async function submitOrder(prevState: any, formData: FormData) {
  'use server'
  try {
    const quantity = Number(formData.get('quantity'))
    if (quantity < 1) throw new Error('Quantity must be >= 1')
    if (quantity > 100) throw new Error('Quantity exceeds limit')

    await db.order.create({ data: { quantity } })
    revalidateTag('orders')
    return { success: true, message: 'Order created' }
  } catch (err: any) {
    return { success: false, error: err.message }
  }
}

export default function OrderForm() {
  const [state, action, pending] = useActionState(submitOrder, null)

  return (
    <form action={action}>
      <input type="number" name="quantity" min={1} disabled={pending} />
      <button type="submit" disabled={pending}>{pending ? 'Submitting...' : 'Submit'}</button>
      {state?.error && <p style={{ color: 'red' }}>{state.error}</p>}
      {state?.success && <p style={{ color: 'green' }}>{state.message}</p>}
    </form>
  )
}

(2) startTransition Pattern

TSX
// app/error-demo/transition.tsx
'use client'
import { useTransition } from 'react'
import { submitFeedback } from './actions'

export function FeedbackForm() {
  const [isPending, startTransition] = useTransition()

  return (
    <form onSubmit={e => {
      e.preventDefault()
      const form = e.currentTarget
      const data = new FormData(form)
      startTransition(async () => {
        try {
          await submitFeedback(data)
          form.reset()
        } catch (err) {
          alert(`Error: ${err}`)
        }
      })
    }}>
      <textarea name="feedback" required disabled={isPending} />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Sending...' : 'Send Feedback'}
      </button>
    </form>
  )
}

(3) try-catch Pattern (within Server Action)

TS
// app/actions/robust-action.ts
'use server'
import { revalidatePath } from 'next/cache'

export async function robustAction(formData: FormData) {
  const id = formData.get('id') as string

  // 1. Parameter Validation
  if (!id || isNaN(Number(id))) {
    throw new Error('Invalid ID')
  }

  // 2. Business Logic + Error Handling
  try {
    await db.item.update({ where: { id: Number(id) }, data: { status: 'processed' } })
    revalidatePath('/items')
    return { ok: true }
  } catch (dbError) {
    console.error('Database error:', dbError)
    throw new Error('Failed to update item in database')
  }
}

▶ Example: Comparison of the Three Error-Handling Patterns (Difficulty: ⭐⭐)

Output:

TEXT
Server action executes and calls revalidatePath() to refresh the page cache.
TSX
// app/error-comparison/page.tsx
import { revalidatePath } from 'next/cache'

// Pattern 1: try-catch within Server Action + Return Value
async function createItem(formData: FormData) {
  'use server'
  try {
    const name = formData.get('name') as string
    if (!name || name.length < 2) return { error: 'Name too short' }
    await fetch('https://jsonplaceholder.typicode.com/posts', { method: 'POST', body: JSON.stringify({ title: name }) })
    revalidatePath('/error-comparison')
    return { success: true }
  } catch (err) {
    return { error: 'Network error' }
  }
}

export default function ErrorComparisonPage() {
  return (
    <div style={{ display: 'grid', gap: 32, padding: 24 }}>
      <section>
        <h2>Pattern 1: Server Action return state</h2>
        <form action={createItem}>
          <input name="name" required />
          <button type="submit">Create</button>
        </form>
      </section>
    </div>
  )
}

Output:

TEXT
Form fields: name. On submit, processes data and refreshes the page cache.
Visible content: Pattern 1: Server Action return state | Create

4. useOptimistic: Optimistic Updates

useOptimistic is a new hook in React 19 that allows you to immediately update the UI before a server action is complete, and then automatically roll back or confirm the update when the actual result is returned.

100%
sequenceDiagram
    participant User
    participant UI as Client UI
    participant SA as Server Action
    participant DB as Database

    User->>UI: Click "Complete Task"
    UI->>UI: useOptimistic → Turn gray immediately + checkmark
    UI->>SA: Call Server Action
    SA->>DB: Update the database
    DB-->>SA: Success
    SA-->>UI: Return Results
    UI->>UI: Comparison with the Actual Situation → Commit or Roll Back
Status UI Display Actual Data User Perception
Status Update ✅ Completed (checkmark) ⏳ In Progress Immediate
Server Confirmation ✅ Completed ✅ Completed No change
Server Rejected ❌ Incomplete (Rollback) ❌ Incomplete Rollback Animation

▶ Example: Optimistic Update of Task Status (Difficulty ⭐⭐⭐)

Output:

TEXT
Sequence diagram showing message flow between participants.
TSX
// app/todos/optimistic-list.tsx
'use client'
import { useOptimistic } from 'react'
import { revalidatePath } from 'next/cache'

type Todo = { id: number; title: string; completed: boolean }

export function OptimisticTodoList({ todos: initialTodos }: { todos: Todo[] }) {
  const [todos, addOptimistic] = useOptimistic(
    initialTodos,
    (state, updatedTodo: Todo) =>
      state.map(t => t.id === updatedTodo.id ? { ...t, completed: !t.completed } : t)
  )

  return (
    <ul>{todos.map(todo => (
      <li key={todo.id} style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
        {todo.title}
        <form action={async (formData: FormData) => {
          'use server'
          const id = Number(formData.get('id'))
          await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, {
            method: 'PATCH', body: JSON.stringify({ completed: true })
          })
          revalidatePath('/todos/optimistic')
        }} onSubmit={e => {
          // Optimistic Update:Update immediately before submitting the form UI
          addOptimistic({ ...todo, completed: !todo.completed })
        }}>
          <input type="hidden" name="id" value={todo.id} />
          <button type="submit">{todo.completed ? 'Undo' : 'Complete'}</button>
        </form>
      </li>
    ))}</ul>
  )
}

Output:

TEXT
On submit, processes data and refreshes the page cache.

5. File Upload Processing

Server Actions can directly receive files via formData. The server processes the file stream and supports size verification, type checking, and storage processing.

(1) Server-Side File Processing

TS
// app/actions/upload.ts
'use server'
import { revalidateTag } from 'next/cache'
import { writeFile } from 'node:fs/promises'
import { join } from 'node:path'

const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf']
const MAX_SIZE = 5 * 1024 * 1024  // 5 MB

export async function uploadAvatar(prevState: any, formData: FormData) {
  const file = formData.get('avatar') as File | null
  if (!file) return { error: 'No file selected' }

  // Type Validation
  if (!ALLOWED_TYPES.includes(file.type)) {
    return { error: 'Invalid file type. Allowed: JPEG, PNG, WebP, PDF' }
  }

  // Size Verification
  if (file.size > MAX_SIZE) {
    return { error: `File too large. Max size: ${MAX_SIZE / 1024 / 1024} MB` }
  }

  try {
    const bytes = await file.arrayBuffer()
    const buffer = Buffer.from(bytes)
    const filename = `${Date.now()}-${file.name.replace(/\s/g, '_')}`
    const path = join('public/uploads', filename)
    await writeFile(path, buffer)

    // Save to the database
    await db.avatar.create({ data: { filename, path: `/uploads/${filename}` } })
    revalidateTag('avatars')
    return { success: true, url: `/uploads/${filename}` }
  } catch (err) {
    return { error: 'Failed to upload file' }
  }
}

(2) Client-side form submission

TSX
// app/profile/avatar-upload.tsx
'use client'
import { useActionState } from 'react'
import { uploadAvatar } from '@/app/actions/upload'

export function AvatarUpload() {
  const [state, action, pending] = useActionState(uploadAvatar, null)

  return (
    <form action={action}>
      <input type="file" name="avatar" accept="image/jpeg,image/png,image/webp" disabled={pending} />
      <button type="submit" disabled={pending}>
        {pending ? 'Uploading...' : 'Upload Avatar'}
      </button>
      {state?.error && <p style={{ color: 'red' }}>{state.error}</p>}
      {state?.success && state?.url && (
        <div>
          <p style={{ color: 'green' }}>Upload successful!</p>
          <img src={state.url} alt="avatar" style={{ width: 100, height: 100, borderRadius: '50%' }} />
        </div>
      )}
    </form>
  )
}

(3) Uploading Multiple Files

TSX
// app/actions/multi-upload.ts
'use server'
import { revalidateTag } from 'next/cache'

export async function uploadGallery(prevState: any, formData: FormData) {
  const files = formData.getAll('photos') as File[]
  if (files.length === 0) return { error: 'No files selected' }
  if (files.length > 10) return { error: 'Max 10 files allowed' }

  const uploaded: string[] = []
  for (const file of files) {
    if (file.size > 5 * 1024 * 1024) continue
    const bytes = await file.arrayBuffer()
    const buffer = Buffer.from(bytes)
    const filename = `${Date.now()}-${file.name}`
    await writeFile(join('public/uploads', filename), buffer)
    uploaded.push(`/uploads/${filename}`)
  }

  revalidateTag('gallery')
  return { success: true, urls: uploaded }
}

▶ Example: Full File Upload + Preview (Difficulty: ⭐⭐⭐)

Output:

TEXT
Renders the uploadGallery component UI.
TSX
// app/upload-demo/page.tsx — File Upload Overview
import { revalidatePath } from 'next/cache'
import { writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import fs from 'node:fs'

export default async function UploadDemoPage() {
  // Read the list of existing files
  const uploadDir = join(process.cwd(), 'public', 'uploads')
  const files = fs.existsSync(uploadDir) ? fs.readdirSync(uploadDir) : []

  return (
    <div style={{ maxWidth: 600, margin: '0 auto', padding: 24 }}>
      <h1>File Upload Demo</h1>

      <form action={async (formData: FormData) => {
        'use server'
        const file = formData.get('file') as File
        if (!file) return

        if (file.size > 2 * 1024 * 1024) {
          return { error: 'File too large (max 2MB)' }
        }

        const bytes = await file.arrayBuffer()
        const buffer = Buffer.from(bytes)
        const filename = `${Date.now()}-${file.name}`
        await writeFile(join('public/uploads', filename), buffer)
        revalidatePath('/upload-demo')
      }}>
        <input type="file" name="file" required />
        <button type="submit">Upload</button>
      </form>

      <div style={{ marginTop: 24 }}>
        <h2>Uploaded Files ({files.length})</h2>
        <ul>{files.map(f => (
          <li key={f}>
            <a href={`/uploads/${f}`} target="_blank">{f}</a>
          </li>
        ))}</ul>
      </div>
    </div>
  )
}

Output:

TEXT
On submit, processes data and refreshes the page cache.
Visible content: File Upload Demo

6. Database Write + Cache Invalidation + Redirected Transaction

Production-level server actions typically consist of three steps: write to the database → clear the cache → redirect the user.

100%
sequenceDiagram
    participant User
    participant Action as Server Action
    participant DB as Database
    participant Cache as Cache Layer

    User->>Action: Submit Form
    Action->>Action: Zod Verification
    Action->>DB: INSERT / UPDATE
    DB-->>Action: Success
    Action->>Cache: revalidateTag / revalidatePath
    Action->>User: redirect Go to a new page/Details Page
Step API Description
Write db.create() / db.update() Prisma / Drizzle Database Operations
Cache Expiration revalidateTag() / revalidatePath() Ensure the list page displays the latest data
Redirect redirect() Redirect to the details page or list page after submission

▶ Example: Full Transaction Mode (Difficulty: ⭐⭐⭐)

Output:

TEXT
Sequence diagram showing message flow between participants.
TSX
// app/posts/create/page.tsx — Write + Cache Expiration + Redirect
import { revalidateTag } from 'next/cache'
import { redirect } from 'next/navigation'
import { z } from 'zod'

const postSchema = z.object({
  title: z.string().min(5).max(200),
  content: z.string().min(20),
  published: z.coerce.boolean().default(false),
})

export default function CreatePostPage() {
  return (
    <form action={async (formData: FormData) => {
      'use server'
      // 1. Verification
      const validated = postSchema.safeParse({
        title: formData.get('title'),
        content: formData.get('content'),
        published: formData.get('published'),
      })
      if (!validated.success) return { errors: validated.error.flatten().fieldErrors }

      // 2. Write to the database
      const post = await fetch('https://jsonplaceholder.typicode.com/posts', {
        method: 'POST',
        body: JSON.stringify({
          title: validated.data.title,
          body: validated.data.content,
          userId: 1,
        })
      }).then(r => r.json())

      // 3. Cache Expiration
      revalidateTag('posts')
      revalidatePath('/posts')

      // 4. Redirect to the new article
      redirect(`/posts/${post.id}`)
    }}>
      <div><input name="title" required placeholder="Post title" /></div>
      <div><textarea name="content" required placeholder="Content" rows={10} /></div>
      <div><label><input name="published" type="checkbox" value="true" /> Published</label></div>
      <button type="submit">Create Post</button>
    </form>
  )
}


### ▶ Example:startTransition + Client-Side Error Handling(Difficulty⭐⭐⭐)

Output:

TEXT
On submit, processes data server-side and redirects.

'use client' import { useTransition, useState } from 'react' import { revalidateTag } from 'next/cache'

async function subscribeNewsletter(formData: FormData) { 'use server' const email = formData.get('email') as string if (!email || !email.includes('@')) throw new Error('Invalid email') await fetch('https://jsonplaceholder.typicode.com/posts', { method: 'POST', body: JSON.stringify({ title: email, body: 'Newsletter subscription' }) }) revalidateTag('newsletter') }

export default function NewsletterForm() { const [isPending, startTransition] = useTransition() const [error, setError] = useState<string | null>(null)

return ( <form onSubmit={e => { e.preventDefault() const form = e.currentTarget const data = new FormData(form) setError(null) startTransition(async () => { try { await subscribeNewsletter(data) form.reset() } catch (err: any) { setError(err.message) } }) }}> <input type="email" name="email" required placeholder="your@email.com" disabled={isPending} /> <button type="submit" disabled={isPending}> {isPending ? 'Subscribing...' : 'Subscribe'} </button> {error && <p style={{ color: 'red' }}>Error: {error}</p>} </form> ) }

TEXT

---

## 7. Server Actions vs API Routes Selection

| Dimension | Server Actions | API Routes |
|:-----|:--------------|:-----------|
| Calling Methods | RSC Payload Agreement | HTTP (REST) |
| Applicable Scenarios | First-Party Form、User Actions | Third Party API、Webhook、Mobile |
| CSRF Protection | ✅ Built-in | ❌ Must be done manually |
| Progressive Enhancement | ✅ Support | ❌ Required JS |
| Type Safety | ✅ Complete TS Type | ⚠️ Manual processing |
| File Upload | ✅ formData Direct Processing | ✅ req Stream Processing |
| Authentication Methods | `auth()` Function | Middleware / JWT |
| Rate Limiting | ⚠️ Must be implemented manually | ✅ Middleware Centralized Processing |
| Cache Expiration | ✅ Built-in revalidate | ✅ Callable revalidateTag |
| Difficulty of Debugging | Low (Function Call) | Medium (HTTP Debugging) |

### (8) ▶ Selection Recommendations

Output:

TEXT
Completed.

graph TB A[What kind of interface is needed?] --> B{Who is the caller?} B -->|Directly called by the browser| C{Is there a form?} B -->|Third Party / Webhook / Mobile| D[API Route] C -->|Form| E[Server Action ✅] C -->|No, JSON interaction| F{Do you need to upload a file?} F -->|is| E F -->|No| D

TEXT

---

## 8. Complete Example:With optimistic updates + File Upload Task Details

// app/tasks/[id]/page.tsx — Server Actions Advanced Comprehensive import { revalidateTag } from 'next/cache' import { writeFile } from 'node:fs/promises' import { join } from 'node:path' import { z } from 'zod'

// ======== Diagrams ======== const commentSchema = z.object({ content: z.string().min(1).max(1000), author: z.string().min(2).max(100), })

// ======== Task Details Page ======== export default async function TaskDetailPage({ params }: { params: { id: string } }) { const task = await fetch(https://jsonplaceholder.typicode.com/todos/${params.id}).then(r => r.json()) const comments = await fetch(https://jsonplaceholder.typicode.com/posts/${params.id}/comments, { next: { tags: [comments-${params.id}] } }).then(r => r.json())

return ( <div style={{ maxWidth: 800, margin: '0 auto', padding: 24 }}> <h1>{task.title}</h1> <p>Status: {task.completed ? '✅ Completed' : '⏳ Pending'}</p>

  {/* Quickly switch states */}
  `<form action={async (formData: FormData) =>` {
    'use server'
    const id = Number(formData.get('id'))
    const completed = formData.get('completed') === 'true'
    await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, {
      method: 'PATCH',
      body: JSON.stringify({ completed: !completed })
    })
    revalidateTag(`task-${id}`)
  }}>
    `<input type="hidden" name="id" value={params.id} />`
    `<input type="hidden" name="completed" value={String(task.completed)} />`
    `<button type="submit">`{task.completed ? 'Mark Pending' : 'Mark Complete'}`</button>`
  `</form>`

  {/* Add a comment */}
  `<h2>`Comments`</h2>`
  `<form action={async (formData: FormData) =>` {
    'use server'
    const validated = commentSchema.safeParse({
      content: formData.get('content'),
      author: formData.get('author'),
    })
    if (!validated.success) return { errors: validated.error.flatten().fieldErrors }
    await fetch('https://jsonplaceholder.typicode.com/comments', {
      method: 'POST',
      body: JSON.stringify({
        postId: Number(params.id),
        name: validated.data.author,
        body: validated.data.content,
        email: 'user@example.com',
      })
    })
    revalidateTag(`comments-${params.id}`)
  }}>
    `<div>`<input name="author" required placeholder="Your name" />`</div>
    `<div>`<textarea name="content" required placeholder="Comment" />`</div>
    `<button type="submit">`Add Comment`</button>`
  `</form>`

  `<ul>`{comments.map((c: any) => (
    `<li key={c.id}>`<strong>`{c.name}:`</strong>` {c.body}`</li>`
  I don't know what this is.

  {/* File Upload */}
  `<h2>`Attachments`</h2>`
  `<form action={async (formData: FormData) =>` {
    'use server'
    const file = formData.get('file') as File
    if (!file || file.size === 0) return { error: 'No file' }
    if (file.size > 5 * 1024 * 1024) return { error: 'File too large' }
    const bytes = await file.arrayBuffer()
    await writeFile(join('public/uploads', `${Date.now()}-${file.name}`), Buffer.from(bytes))
    revalidateTag(`attachments-${params.id}`)
  }}>
    `<input type="file" name="file" />`
    `<button type="submit">`Upload`</button>`
  `</form>`
`</div>`

) }


❓ FAQ

Q Can useOptimistic and useActionState be used together?
A Yes. useOptimistic is used to update the UI in advance, while useActionState is used to retrieve the final state actually returned by the server. A common pattern is to use useOptimistic to manage immediate UI changes and useActionState to confirm or roll back after receiving the server response.
Q What file types are supported for file uploads in Server Action?
A Any file type supported by browsers is acceptable. The server can verify the file by reading file.type (MIME type) and file.size (number of bytes). The recommended approach is to save the file to the file system or cloud storage (S3/R2) and store a path reference to it in the database.
Q How is redirect() used in a Server Action?
A redirect() must be imported from next/navigation. When called in a Server Action, it throws a special redirection exception. It must be called outside a try-catch block—if placed inside a try block, it will be caught by the catch block. The correct pattern is: validate → write → revalidate → redirect (not inside a try block).
Q What is the timeout for Server Actions?
A Vercel Free Plan: 10 seconds (Serverless Function), 60 seconds (Pro). Self-hosted: Determined by your Node.js server configuration (unlimited by default). For long-running operations (such as video processing), we recommend breaking them down into asynchronous tasks with Webhook callbacks, rather than waiting synchronously within a Server Action.
Q Can a Server Action call the set-cookie function multiple times?
A Yes. Use the cookies() function to set the response header: const cookieStore = cookies(); cookieStore.set('theme', 'dark'). However, note that cookie operations in a Server Action take effect in batches; you cannot set a cookie and then read it within the same action.
Q In what scenarios should you use a Server Action instead of an API Route?
A There are three scenarios where Server Actions are preferred: ① First-party user actions (form submissions, button clicks); ② Situations requiring progressive enhancement (still usable even when JavaScript is disabled); ③ Tight integration with the RSC caching system (revalidateTag/revalidatePath). The advantages of API Routes are: ① Third-party integrations (mobile apps, webhooks); ② No need for an HTML page context; ③ Requirement for standard RESTful APIs.

📖 Summary


📝 Exercises

  1. Basic Exercise (⭐): Create a app/quick-todo/page.tsx and use an inline Server Action to add and delete to-dos. In the Server Action, use a try-catch block to handle errors and return { error: string } to the client for display.

  2. Advanced Exercise (⭐⭐): Build a app/gallery/page.tsx multi-image upload page that supports selecting up to 5 images at a time. In the Server Action, validate the file type (images only) and size (≤ 2MB per image), and display a list of thumbnails after upload. Implement writeFile storage and revalidatePath refresh in the Server Action.

  3. Challenge (⭐⭐⭐): Implement a "Task Board" page that includes three status columns (Todo / In Progress / Done). Use useOptimistic to implement real-time UI updates when switching statuses via drag-and-drop (no actual dragging is required; use buttons to switch instead). After a Server Action is written, automatically revalidateTag refresh all columns. Add a file upload feature to each task card.

Web-Tutorial.com

Web-Tutorial Tech Team

A team of developers maintaining programming tutorials. Each tutorial is written and reviewed by developers with expertise in that field. We work to keep our content accurate and reliable — if you spot an issue, please let us know.

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏