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:
- The front end sends
fetchto/api/upload(2-second upload) - The server uses
sharpto process thumbnails (3 seconds) - Call
revalidateTagto refresh the attachment list (0.5 seconds) - Redirect back to the details page (0.5 seconds)
- Total: ~6 seconds
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
useOptimisticto immediately display "virtual" attachments in the UI + unified upload processing via Server Action + try-catch error handling.
// 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
(1) useActionState Pattern (Recommended)
// 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
// 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)
// 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:
Server action executes and calls revalidatePath() to refresh the page cache.
// 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:
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.
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:
Sequence diagram showing message flow between participants.
// 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:
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
// 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
// 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
// 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:
Renders the uploadGallery component UI.
// 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:
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.
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:
Sequence diagram showing message flow between participants.
// 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:
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>
)
}
---
## 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:
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
---
## 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
useOptimistic and useActionState be used together?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.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.redirect() used in a Server Action?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).set-cookie function multiple times?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.📖 Summary
- Three error-handling modes: useActionState (recommended), startTransition, try-catch
useOptimisticUpdate the UI in real time before the Server Action completes, and automatically roll back in case of failure- For file uploads, use
formData.get('file') as Fileto retrieve the file stream and verify the file type and size - Transaction flow: Zod validation → database write → revalidateTag → redirect
- Server Actions are suitable for first-party form operations, while API Routes are suitable for third-party integrations
- For large file uploads, it is recommended to split them into asynchronous tasks for processing
redirect()Cannot be called within atryblock; must be placed at the end of the transaction
📝 Exercises
-
Basic Exercise (⭐): Create a
app/quick-todo/page.tsxand 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. -
Advanced Exercise (⭐⭐): Build a
app/gallery/page.tsxmulti-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. ImplementwriteFilestorage andrevalidatePathrefresh in the Server Action. -
Challenge (⭐⭐⭐): Implement a "Task Board" page that includes three status columns (Todo / In Progress / Done). Use
useOptimisticto 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, automaticallyrevalidateTagrefresh all columns. Add a file upload feature to each task card.



