Files
gh-jezweb-claude-skills-ski…/templates/server-actions-form.tsx
2025-11-30 08:25:04 +08:00

535 lines
13 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Next.js 16 - Server Actions for Form Handling
*
* This template shows comprehensive Server Actions patterns including:
* - Basic form handling
* - Validation with Zod
* - Loading states
* - Error handling
* - Optimistic updates
* - File uploads
*/
'use server'
import { z } from 'zod'
import { revalidateTag } from 'next/cache'
import { redirect } from 'next/navigation'
// ============================================================================
// Example 1: Basic Server Action
// ============================================================================
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
const content = formData.get('content') as string
// Save to database
await fetch('https://api.example.com/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, content }),
})
// Revalidate cache
revalidateTag('posts', 'max')
// Redirect to posts list
redirect('/posts')
}
// Basic form component
export function CreatePostForm() {
return (
<form action={createPost}>
<label>
Title:
<input type="text" name="title" required />
</label>
<label>
Content:
<textarea name="content" required />
</label>
<button type="submit">Create Post</button>
</form>
)
}
// ============================================================================
// Example 2: Server Action with Validation
// ============================================================================
const PostSchema = z.object({
title: z.string().min(3, 'Title must be at least 3 characters'),
content: z.string().min(10, 'Content must be at least 10 characters'),
tags: z.array(z.string()).min(1, 'At least one tag is required'),
})
type ActionState = {
errors?: {
title?: string[]
content?: string[]
tags?: string[]
_form?: string[]
}
success?: boolean
}
export async function createPostWithValidation(
prevState: ActionState,
formData: FormData
): Promise<ActionState> {
// Parse form data
const rawData = {
title: formData.get('title'),
content: formData.get('content'),
tags: formData.get('tags')?.toString().split(',') || [],
}
// Validate
const parsed = PostSchema.safeParse(rawData)
if (!parsed.success) {
return {
errors: parsed.error.flatten().fieldErrors,
}
}
// Save to database
try {
await fetch('https://api.example.com/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(parsed.data),
})
revalidateTag('posts', 'max')
return { success: true }
} catch (error) {
return {
errors: {
_form: ['Failed to create post. Please try again.'],
},
}
}
}
// Form with validation errors
'use client'
import { useFormState, useFormStatus } from 'react-dom'
function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? 'Creating...' : 'Create Post'}
</button>
)
}
export function ValidatedPostForm() {
const [state, formAction] = useFormState(createPostWithValidation, {})
return (
<form action={formAction}>
<div>
<label>
Title:
<input type="text" name="title" required />
</label>
{state.errors?.title && (
<p className="error">{state.errors.title[0]}</p>
)}
</div>
<div>
<label>
Content:
<textarea name="content" required />
</label>
{state.errors?.content && (
<p className="error">{state.errors.content[0]}</p>
)}
</div>
<div>
<label>
Tags (comma-separated):
<input type="text" name="tags" placeholder="nextjs, react, tutorial" />
</label>
{state.errors?.tags && (
<p className="error">{state.errors.tags[0]}</p>
)}
</div>
{state.errors?._form && (
<p className="error">{state.errors._form[0]}</p>
)}
{state.success && (
<p className="success">Post created successfully!</p>
)}
<SubmitButton />
</form>
)
}
// ============================================================================
// Example 3: Server Action with Optimistic Updates
// ============================================================================
'use server'
import { updateTag } from 'next/cache'
export async function likePost(postId: string) {
await fetch(`https://api.example.com/posts/${postId}/like`, {
method: 'POST',
})
// Use updateTag for immediate refresh (read-your-writes)
updateTag('posts')
}
// Client component with optimistic updates
'use client'
import { useOptimistic } from 'react'
import { likePost } from './actions'
export function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
const [optimisticLikes, addOptimisticLike] = useOptimistic(
initialLikes,
(state, amount: number) => state + amount
)
async function handleLike() {
// Update UI immediately
addOptimisticLike(1)
// Sync with server
await likePost(postId)
}
return (
<button onClick={handleLike}>
{optimisticLikes} likes
</button>
)
}
// ============================================================================
// Example 4: Server Action with File Upload
// ============================================================================
'use server'
import { writeFile } from 'fs/promises'
import { join } from 'path'
export async function uploadImage(formData: FormData) {
const file = formData.get('image') as File
if (!file) {
return { error: 'No file provided' }
}
// Validate file type
const validTypes = ['image/jpeg', 'image/png', 'image/webp']
if (!validTypes.includes(file.type)) {
return { error: 'Invalid file type. Only JPEG, PNG, and WebP are allowed.' }
}
// Validate file size (max 5MB)
const maxSize = 5 * 1024 * 1024 // 5MB
if (file.size > maxSize) {
return { error: 'File too large. Maximum size is 5MB.' }
}
// Save file
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
const filename = `${Date.now()}-${file.name}`
const path = join(process.cwd(), 'public', 'uploads', filename)
await writeFile(path, buffer)
return {
success: true,
url: `/uploads/${filename}`,
}
}
// File upload form
'use client'
import { useState } from 'react'
import { uploadImage } from './actions'
export function ImageUploadForm() {
const [preview, setPreview] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
async function handleSubmit(formData: FormData) {
const result = await uploadImage(formData)
if (result.error) {
setError(result.error)
} else if (result.url) {
setPreview(result.url)
setError(null)
}
}
return (
<form action={handleSubmit}>
<input
type="file"
name="image"
accept="image/jpeg,image/png,image/webp"
required
/>
<button type="submit">Upload Image</button>
{error && <p className="error">{error}</p>}
{preview && (
<div>
<h3>Uploaded Image:</h3>
<img src={preview} alt="Uploaded" width={300} />
</div>
)}
</form>
)
}
// ============================================================================
// Example 5: Server Action with Progressive Enhancement
// ============================================================================
'use server'
export async function subscribe(formData: FormData) {
const email = formData.get('email') as string
await fetch('https://api.example.com/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
})
return { success: true, message: 'Subscribed successfully!' }
}
// Form works without JavaScript (progressive enhancement)
export function SubscribeForm() {
return (
<form action={subscribe}>
<input
type="email"
name="email"
placeholder="Enter your email"
required
/>
<button type="submit">Subscribe</button>
</form>
)
}
// Enhanced with JavaScript
'use client'
import { useFormState } from 'react-dom'
export function EnhancedSubscribeForm() {
const [state, formAction] = useFormState(subscribe, null)
return (
<form action={formAction}>
<input
type="email"
name="email"
placeholder="Enter your email"
required
/>
<button type="submit">Subscribe</button>
{state?.success && (
<p className="success">{state.message}</p>
)}
</form>
)
}
// ============================================================================
// Example 6: Server Action with Multi-Step Form
// ============================================================================
'use server'
type Step1Data = { name: string; email: string }
type Step2Data = { address: string; city: string }
type FormData = Step1Data & Step2Data
export async function submitMultiStepForm(data: FormData) {
// Validate all data
const schema = z.object({
name: z.string().min(2),
email: z.string().email(),
address: z.string().min(5),
city: z.string().min(2),
})
const parsed = schema.safeParse(data)
if (!parsed.success) {
return {
errors: parsed.error.flatten().fieldErrors,
}
}
// Save to database
await fetch('https://api.example.com/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(parsed.data),
})
return { success: true }
}
// Multi-step form component
'use client'
import { useState } from 'react'
export function MultiStepForm() {
const [step, setStep] = useState(1)
const [formData, setFormData] = useState<Partial<FormData>>({})
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
if (step === 1) {
const form = e.currentTarget
setFormData({
...formData,
name: (form.elements.namedItem('name') as HTMLInputElement).value,
email: (form.elements.namedItem('email') as HTMLInputElement).value,
})
setStep(2)
} else {
const form = e.currentTarget
const finalData = {
...formData,
address: (form.elements.namedItem('address') as HTMLInputElement).value,
city: (form.elements.namedItem('city') as HTMLInputElement).value,
} as FormData
const result = await submitMultiStepForm(finalData)
if (result.success) {
alert('Form submitted successfully!')
}
}
}
return (
<form onSubmit={handleSubmit}>
{step === 1 && (
<>
<h2>Step 1: Personal Info</h2>
<input name="name" placeholder="Name" defaultValue={formData.name} required />
<input name="email" type="email" placeholder="Email" defaultValue={formData.email} required />
<button type="submit">Next</button>
</>
)}
{step === 2 && (
<>
<h2>Step 2: Address</h2>
<input name="address" placeholder="Address" defaultValue={formData.address} required />
<input name="city" placeholder="City" defaultValue={formData.city} required />
<button type="button" onClick={() => setStep(1)}>Back</button>
<button type="submit">Submit</button>
</>
)}
</form>
)
}
// ============================================================================
// Example 7: Server Action with Rate Limiting
// ============================================================================
'use server'
import { headers } from 'next/headers'
const rateLimitMap = new Map<string, { count: number; resetAt: number }>()
export async function submitContactForm(formData: FormData) {
const headersList = await headers()
const ip = headersList.get('x-forwarded-for') || 'unknown'
// Check rate limit (5 requests per hour)
const now = Date.now()
const rateLimit = rateLimitMap.get(ip)
if (rateLimit) {
if (now < rateLimit.resetAt) {
if (rateLimit.count >= 5) {
return { error: 'Too many requests. Please try again later.' }
}
rateLimit.count++
} else {
// Reset rate limit
rateLimitMap.set(ip, { count: 1, resetAt: now + 3600000 }) // 1 hour
}
} else {
rateLimitMap.set(ip, { count: 1, resetAt: now + 3600000 })
}
// Process form
const name = formData.get('name') as string
const message = formData.get('message') as string
await fetch('https://api.example.com/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, message }),
})
return { success: true }
}
/**
* Summary:
*
* Server Actions patterns:
* 1. ✅ Basic form handling with formData
* 2. ✅ Validation with Zod (safeParse)
* 3. ✅ Loading states with useFormStatus
* 4. ✅ Error handling with useFormState
* 5. ✅ Optimistic updates with useOptimistic
* 6. ✅ File uploads
* 7. ✅ Progressive enhancement (works without JS)
* 8. ✅ Multi-step forms
* 9. ✅ Rate limiting
*
* Best practices:
* - Always validate on server (never trust client input)
* - Use revalidateTag() for background revalidation
* - Use updateTag() for immediate refresh (forms, settings)
* - Return errors instead of throwing (better UX)
* - Use TypeScript for type safety
* - Add rate limiting for public forms
*/