Form Patterns File Upload
Validate file uploads with Zod, implement drag-and-drop, and show image previews - all with proper type safety.
Search across all documentation pages
Validate file uploads with Zod, implement drag-and-drop, and show image previews - all with proper type safety.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Quick-reference recipe card - copy-paste ready.
import { z } from "zod";
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/webp"];
const FileSchema = z
.instanceof(File)
.refine((f) => f.size <= MAX_FILE_SIZE, "File must be under 5MB")
.refine((f) => ACCEPTED_TYPES.includes(f.type), "Only JPEG, PNG, or WebP");
const UploadSchema = z.object({
title: z.string().min(1),
file: FileSchema,
});
// For multiple files
const MultiFileSchema = z.object({
files: z
.array(FileSchema)
.min(1, "At least one file")
.max(5, "Maximum 5 files"),
});When to reach for this: When your form includes file uploads that need client-side validation for type, size, or count before sending to the server.
"use client";
import { useState, useRef, useCallback } from "react";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const MAX_SIZE = 5 * 1024 * 1024;
const ACCEPTED = ["image/jpeg", "image/png", "image/webp"];
const Schema = z.object({
title: z.string().min(1, "Title required"),
images: z
.array(
z
.instanceof(File)
.refine((f) => f.size <= MAX_SIZE, "Max 5MB per file")
.refine((f) => ACCEPTED.includes(f.type), "Only JPEG, PNG, WebP")
)
.min(1, "Upload at least one image")
.max(4, "Maximum 4 images"),
});
type FormData = z.infer<typeof Schema>;
export function ImageUploadForm() {
const [previews, setPreviews] = useState<string[]>([]);
const [isDragging, setIsDragging] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const {
register,
handleSubmit,
control,
formState: { errors, isSubmitting },
setValue,
watch,
} = useForm<FormData>({
resolver: zodResolver(Schema),
defaultValues: { title: "", images: [] },
});
const images = watch("images");
const updateFiles = useCallback(
(files: File[]) => {
setValue("images", files, { shouldValidate: true });
const urls = files.map((f) => URL.createObjectURL(f));
setPreviews((prev) => {
prev.forEach(URL.revokeObjectURL);
return urls;
});
},
[setValue]
);
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files ?? []);
updateFiles(files);
}
function handleDrop(e: React.DragEvent) {
e.preventDefault();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
updateFiles(files);
}
function removeFile(index: number) {
const next = images.filter((_, i) => i !== index);
updateFiles(next);
}
async function onSubmit(data: FormData) {
const fd = new FormData();
fd.append("title", data.title);
data.images.forEach((file) => fd.append("images", file));
await fetch("/api/upload", { method: "POST", body: fd });
alert("Uploaded!");
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="max-w-lg space-y-4">
<div>
<input
{...register("title")}
placeholder="Title"
className="w-full rounded border p-2"
/>
{errors.title && <p className="text-sm text-red-600">{errors.title.message}</p>}
</div>
{/* Drag-and-drop zone */}
<div
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
className={`cursor-pointer rounded-lg border-2 border-dashed p-8 text-center transition ${
isDragging ? "border-blue-500 bg-blue-50" : "border-gray-300"
}`}
>
<p className="text-sm text-gray-600">
Drag & drop images here, or click to browse
</p>
<p className="mt-1 text-xs text-gray-400">
JPEG, PNG, WebP - max 5MB each, up to 4 files
</p>
<input
ref={inputRef}
type="file"
accept={ACCEPTED.join(",")}
multiple
onChange={handleFileChange}
className="hidden"
/>
</div>
{errors.images && (
<p className="text-sm text-red-600">
{errors.images.message ?? errors.images.root?.message}
</p>
)}
{/* Previews */}
{previews.length > 0 && (
<div className="grid grid-cols-4 gap-2">
{previews.map((src, i) => (
<div key={src} className="relative">
<img src={src} alt={`Preview ${i + 1}`} className="h-24 w-full rounded object-cover" />
<button
type="button"
onClick={() => removeFile(i)}
className="absolute right-1 top-1 rounded-full bg-red-500 px-1.5 text-xs text-white"
>
X
</button>
</div>
))}
</div>
)}
<button
type="submit"
disabled={isSubmitting}
className="rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
>
{isSubmitting ? "Uploading..." : "Upload"}
</button>
</form>
);
}What this demonstrates:
z.instanceof(File) with size and type refinementsURL.createObjectURL for image previewsFormData construction for multipart uploadz.instanceof(File) checks that the value is a browser File objectURL.createObjectURL creates a temporary URL for the file blob - revoke it when done to free memory<input type="file"> is triggered programmatically via ref.click()setValue("images", files, { shouldValidate: true }) updates the form and triggers validationSingle file with Controller:
<Controller
name="avatar"
control={control}
render={({ field: { onChange, value }, fieldState: { error } }) => (
<div>
<input
type="file"
accept="image/*"
onChange={(e) => onChange(e.target.files?.[0])}
/>
{value && <p className="text-sm">{value.name}</p>}
{error && <p className="text-sm text-red-600">{error.message}</p>}
</div>
)}
/>Server Action file upload:
// action.ts
"use server";
export async function uploadAction(prev: State, formData: FormData) {
const file = formData.get("file") as File;
if (!file || file.size === 0) return { error: "No file selected" };
if (file.size > 5 * 1024 * 1024) return { error: "File too large" };
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
await fs.writeFile(`/uploads/${file.name}`, buffer);
return { success: true };
}Progress tracking:
function useUploadProgress() {
const [progress, setProgress] = useState(0);
async function upload(file: File) {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) setProgress(Math.round((e.loaded / e.total) * 100));
});
// ... XHR setup
}
return { upload, progress };
}// File is a global browser type - no import needed
const FileSchema = z.instanceof(File);
type FileType = z.infer<typeof FileSchema>; // File
// For server-side (Node), use Buffer or Uint8Array instead
const ServerFileSchema = z.instanceof(Buffer);
// Accept attribute types
const MIME_TYPES = ["image/jpeg", "image/png"] as const;
type MimeType = (typeof MIME_TYPES)[number];z.instanceof(File) fails on the server - File is a browser API. Fix: Use separate schemas for client and server validation. On the server, validate the FormData entry directly.
Memory leaks with createObjectURL - Each call allocates a blob URL. Fix: Call URL.revokeObjectURL(url) when the preview is removed or the component unmounts.
<input type="file"> is uncontrolled - You cannot set its value programmatically (security restriction). Fix: Use a hidden input and manage state separately with setValue.
Large files block the main thread - Reading large files for preview can freeze the UI. Fix: Use createObjectURL (no read needed) instead of FileReader.readAsDataURL.
HEIC/HEIF on iOS - iOS may send HEIC files even when you specify accept="image/jpeg". Fix: Include image/heic in your accept list, or convert server-side.
| Alternative | Use When | Don't Use When |
|---|---|---|
| UploadThing | You want a managed upload service with React components | You need full control over upload infrastructure |
| react-dropzone | You need a polished drag-and-drop library with edge cases handled | A simple custom dropzone suffices |
| Presigned URLs (S3) | You want direct browser-to-storage upload without proxying through your server | You need server-side processing before storage |
| tus-js-client | You need resumable uploads for large files | Files are small and upload quickly |
z.instanceof(File) checks that the value is a browser File object at runtime.refine() to add size and type checks: .refine(f => f.size <= 5_000_000, "Max 5MB")File does not exist in Node.jscreateObjectURL returns a blob URL instantly without reading the file contentsFileReader.readAsDataURL blocks the main thread for large filesURL.revokeObjectURL(url) when the preview is removed to free memory<div onClick={() => inputRef.current?.click()}>
Drop files here
<input ref={inputRef} type="file" className="hidden" onChange={handleFileChange} />
</div>ref.click()const fd = new FormData();
fd.append("title", data.title);
data.images.forEach(file => fd.append("images", file));
await fetch("/api/upload", { method: "POST", body: fd });append in a loop for multiple files with the same field nameContent-Type header; the browser sets it with the boundary automaticallysetValue("images", files, { shouldValidate: true }) updates the form value and immediately triggers Zod validationshouldValidate, the form would not show errors until the next submission attemptFile class is a browser-only API and does not exist in Node.jsFormData entries, not File objects from your client schemaaccept attributeaccept attribute is a hint, not a strict filter on mobile browsersimage/heic in your accept list, or convert HEIC to JPEG server-sideconst FileSchema = z.instanceof(File);
type FileType = z.infer<typeof FileSchema>; // File
// For server-side (Node.js), use Buffer:
const ServerFileSchema = z.instanceof(Buffer);const MIME_TYPES = ["image/jpeg", "image/png"] as const;
type MimeType = (typeof MIME_TYPES)[number];
// "image/jpeg" | "image/png"<input type="file"> is uncontrolled and cannot have its value set programmaticallyController wraps the input and manages the value through RHF's stateonChange={(e) => onChange(e.target.files?.[0])} inside the Controller's render propXMLHttpRequest with xhr.upload.addEventListener("progress", callback)e.loaded and e.total for percentage calculationfetch does not support upload progress nativelyReviewed by Chris St. John·Last updated Jul 19, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥