File Upload
File uploads break the default JSON action model. This recipe shows how to use a FormData action, validate file type and size, show a preview, and surface a loading indicator.
Why it matters
File uploads are fundamentally different from text-field submissions. Files must travel over the wire as multipart/form-data, not JSON. That means you need a FormDataServerAction instead of the withZod JSON wrapper. Validation also shifts: instead of a Zod schema on an object, you inspect the File object's size, type, and name on the server.
Client-side validation (type/size checks before upload) is implemented with a custom schema using Zod's .refine(). This recipe covers the full pattern: action, form, preview, and a graceful loading state.
Full Example — Avatar Upload
'use server'
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp']
const MAX_SIZE_BYTES = 5 * 1024 * 1024 // 5 MB
export async function uploadAvatarAction(
prevState: unknown,
formData: FormData
) {
const file = formData.get('avatar') as File | null
if (!file || file.size === 0) {
return { errors: { avatar: ['Please select a file'] } }
}
if (file.size > MAX_SIZE_BYTES) {
return { errors: { avatar: ['File must be under 5 MB'] } }
}
if (!ALLOWED_TYPES.includes(file.type)) {
return { errors: { avatar: ['Only JPEG, PNG, and WebP images are allowed'] } }
}
// Upload to your storage provider (S3, Cloudflare R2, Vercel Blob, etc.)
const url = await storage.upload(file)
// Update the user's profile with the new avatar URL
await db.profiles.update({
where: { userId: session.userId },
data: { avatarUrl: url },
})
return { success: true, url }
}'use client'
import { useActionForm } from 'hookform-action'
import { useRef, useState } from 'react'
import { z } from 'zod'
import { uploadAvatarAction } from './actions'
// Client-side schema for early validation (before upload)
const avatarSchema = z.object({
avatar: z
.custom<FileList>()
.refine((files) => files?.length > 0, 'Please select a file')
.refine(
(files) => files?.[0]?.size <= 5 * 1024 * 1024,
'File must be under 5 MB'
)
.refine(
(files) => ['image/jpeg', 'image/png', 'image/webp'].includes(files?.[0]?.type),
'Only JPEG, PNG, and WebP images are allowed'
),
})
type AvatarResult = {
success?: boolean
url?: string
errors?: { avatar?: string[] }
}
export function AvatarUploadForm() {
const [preview, setPreview] = useState<string | null>(null)
const previewUrlRef = useRef<string | null>(null)
const {
register,
handleSubmit,
formState: { errors, isPending, isSubmitSuccessful, actionResult },
} = useActionForm<{ avatar: FileList }, AvatarResult>(uploadAvatarAction, {
defaultValues: { avatar: undefined as unknown as FileList },
schema: avatarSchema,
validationMode: 'onChange',
onSuccess: (result) => {
// Revoke the local preview URL to free memory
if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current)
},
})
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
// Generate a local preview before uploading
if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current)
const url = URL.createObjectURL(file)
previewUrlRef.current = url
setPreview(url)
}
return (
<div className="space-y-4">
{/* Preview */}
{preview && !isSubmitSuccessful && (
<div className="w-24 h-24 rounded-full overflow-hidden border-2 border-brand-500">
<img src={preview} alt="Preview" className="w-full h-full object-cover" />
</div>
)}
{/* Confirmed avatar */}
{isSubmitSuccessful && actionResult?.url && (
<div className="space-y-2">
<div className="w-24 h-24 rounded-full overflow-hidden border-2 border-green-500">
<img src={actionResult.url} alt="Your avatar" className="w-full h-full object-cover" />
</div>
<p className="text-green-400 text-sm">Avatar updated!</p>
</div>
)}
<form onSubmit={handleSubmit()}>
<div className="space-y-2">
<label htmlFor="avatar" className="text-sm font-medium text-gray-300">
Choose an image
</label>
<input
id="avatar"
type="file"
accept="image/jpeg,image/png,image/webp"
{...register('avatar', { onChange: handleFileChange })}
className="block w-full text-sm text-gray-400 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:bg-brand-600 file:text-white hover:file:bg-brand-500"
/>
{errors.avatar && (
<p className="text-red-400 text-sm">{errors.avatar.message}</p>
)}
</div>
<button
type="submit"
disabled={isPending || !preview}
className="mt-4 px-4 py-2 bg-brand-600 hover:bg-brand-500 disabled:bg-gray-700 text-white rounded-lg transition-colors"
>
{isPending ? (
<span className="flex items-center gap-2">
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" className="opacity-25" />
<path fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" className="opacity-75" />
</svg>
Uploading…
</span>
) : (
'Upload Avatar'
)}
</button>
</form>
</div>
)
}Key Concepts
FormDataServerAction (arity 2)File uploads require the (prevState, formData) => Promise signature. The hook detects this automatically (by checking the function's length) and sends the data as multipart/form-data instead of JSON.
z.custom<FileList>().refine()Zod does not have a built-in file type. Use z.custom<FileList>() with .refine() for client-side validation. Avoid z.instanceof(File) — it fails in SSR environments where File is not defined.
URL.createObjectURL + cleanupUse URL.createObjectURL for instant image previews. Always call URL.revokeObjectURL when the preview is no longer needed to prevent memory leaks. Store the URL in a useRef to access it across renders.
Next.js Server Action file size limitNext.js Server Actions have a default body size limit of 1 MB. For larger files, configure experimental.serverActionsBodySizeLimit in next.config.mjs, or upload directly to your storage provider from the client (signed URL pattern).
⚠️ Pitfalls
- ⚠
Using
withZodfor file uploadswithZodserialises data as JSON. Files cannot be serialised to JSON — you will receive an empty object. Always use the raw FormData action signature for file uploads. - ⚠
Using
setValuefor file inputsBrowser security prevents programmatically setting file input values. Use
registernormally and listen toonChangefor the preview logic. Do not try to control the file input's value viasetValue. - ⚠
Forgetting to revoke object URLs
Every
URL.createObjectURLcall holds a reference to the file in memory. Revoke it inonSuccessor in auseEffectcleanup to avoid memory leaks in long-running sessions.