Tier 3 · SpecializedRecipe #9

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

actions.ts — FormData action (arity 2)
'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 }
}
avatar-upload-form.tsx
'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 + cleanup

Use 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 limit

Next.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 withZod for file uploads

    withZod serialises 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 setValue for file inputs

    Browser security prevents programmatically setting file input values. Use register normally and listen to onChange for the preview logic. Do not try to control the file input's value via setValue.

  • Forgetting to revoke object URLs

    Every URL.createObjectURL call holds a reference to the file in memory. Revoke it in onSuccess or in a useEffect cleanup to avoid memory leaks in long-running sessions.

Related