Picture this: You're working on a mature React application with forms that have evolved over years. Nested objects, array fields, complex validation and maybe even some legacy code. Then comes the requirement:
"We need to add file uploads to this form."
Your first thought? "Easy, just use FormData
and multipart uploads!"
But then you open the form component and see something this:
// The existing form structure that gives you nightmares
const { control, handleSubmit } = useForm<{
project: {
name: string;
collaborators: Array<{
email: string;
permissions: string[];
}>;
timeline: {
start: Date;
milestones: Array<{
title: string;
description: string;
}>;
};
};
// ...and 20 more fields
}>();
Suddenly, converting everything to FormData
feels like impossible and just not ideal. Let me show you how we tackled this without rewriting our entire form (and backend!).
The Problem of File Uploads
Our requirements were:
✅ Maintain the existing complex JSON structure
✅ Add multiple file uploads
✅ Single API request (no race conditions!)
✅ No massive refactoring
The Obvious (But Flawed) Solution: Two Separate Requests
Submitting form data first, then uploading files separately seemed logical.
But:
❌ What if the form submission succeeds but file upload fails?
❌ How do we handle partial failures?
❌ Double the API surface = double the maintenance
We needed a way to send everything in one payload. Here's what we built.
Approach 1: The Straightforward FormData Method
Best for: Simple forms, quick implementations
Let's start with the solution you wish you could use.
Simple Upload Component
// app/components/simple-upload.tsx
"use client";
export default function SimpleUploadForm() {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData();
files.forEach((file) => formData.append("files", file));
const res = await fetch("/api/simple-upload", {
method: "POST",
body: formData, // No Content-Type header!
});
};
return (
<Card>
<CardContent>
<form onSubmit={handleSubmit}>
<Input type="file" multiple />
<Button type="submit">Upload</Button>
</form>
</CardContent>
</Card>
);
}
API Route
// app/api/simple-upload/route.ts
export async function POST(request: NextRequest) {
const formData = await request.formData();
const files = formData.getAll("files") as File[];
files.forEach(async (file) => {
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
// Write to filesystem or S3
});
return NextResponse.json({ success: true });
}
✅ Why this works:
- Native browser support
- Streams files directly
- No size conversion overhead
❌ Why it failed us:
- Our existing form wasn't compatible with
FormData
- Converting nested objects to flat key-value pairs would require:
- Frontend restructuring
- Backend parameter re-mapping
- Breaking changes for mobile clients
Approach 2: The JSON Base64 Shuffle
Best for: Keeping complex JSON structures intact
Instead of breaking our form, we converted files to Base64 and embedded them in JSON. So the flow will be:
Frontend will encode it and send to backend.
Backend will then decode it and be able to treat it (Save to an S3 bucket, save to database, whatever.).
Complex Upload Component
// app/components/complex-upload.tsx
"use client";
const onSubmit: SubmitHandler<FormInputs> = async (data) => {
const files = await Promise.all(
Array.from(data.files).map(async (file) => {
const base64 = await readFileAsBase64(file);
return {
name: file.name,
type: file.type,
size: file.size,
base64: base64.split(",")[1], // Strip metadata
};
})
);
const payload = {
user: data.user,
attachments: files,
};
await fetch("/api/complex-upload", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
};
// FileReader Helper Function
const readFileAsBase64 = (file: File) => {
return new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onload = (e) => resolve(e.target?.result as string);
reader.readAsDataURL(file);
});
};
API Route
// app/api/complex-upload/route.ts
export async function POST(request: NextRequest) {
const { user, attachments } = await request.json();
attachments.forEach((file) => {
const buffer = Buffer.from(file.base64, "base64");
fs.writeFileSync(`./uploads/${file.name}`, buffer);
});
return NextResponse.json({
user,
uploadedFiles: attachments.map(f => f.name)
});
}
✅ Why this works:
- Existing structure maintained: No need to flatten nested objects
- Single request: Everything succeeds or fails together
- Backend agnostic: Works with REST, GraphQL, or even tRPC
Lessons
1. React Hook Form Quirks
When using useForm
with file inputs, handle the FileList
manually:
{...register("files", {
validate: (files) => files?.length > 0 || "Required"
})}
2. Memory Management
Converting 100MB files to base64?
🚨 Your users' browsers will cry.
We added:
if (file.size > 10 * 1024 * 1024) {
throw new Error("File too big");
}
3. Backend Security Checks
Never trust incoming base64. Validate MIME types properly:
// Validate MIME type from actual content
import fileType from 'file-type';
const buffer = Buffer.from(file.base64, 'base64');
const type = await fileType.fromBuffer(buffer);
if (!type?.mime.startsWith('image/')) {
throw new Error('Invalid file type');
}
When to Choose Which Approach?
Feature | FormData | JSON + Base64 |
---|---|---|
Payload Structure | Flat key-value | Complex nested JSON |
File Size | Better for large files | Keep under 10MB |
Browser Support | Universal | Modern browsers |
Existing API Surface | Needs modification | Works as-is |
Try It Yourself
My complete example repo shows both approaches side-by-side with both the frontend and backend ready to go in one app:
git clone https://github.com/brinobruno/file-upload-poc-next
cd file-upload-poc-next
follow README.md
✅ The demo includes:
- Side-by-side form comparison
- Error handling implementations
- File size validation
- UI feedback states
Parting Thoughts
This solution helped us ship file uploads without regressing existing functionality.
Is it perfect? No. But in complex codebases, pragmatic solutions often win over theoretical best practices.
🚀 Coming soon: Implementing this pattern in Elixir Phoenix, as I'm learning this framework myself.
👉 What file upload challenges have you faced? Drop your thoughts in the comments!
References
here are some authoritative sources and references you could explore for deeper insights:
WebSockets And SSE Documentation:
MDN Web Docs: File
MDN Web Docs: FormData
MDN Web Docs: Base64
Let's connect
I'll share my relevant socials in case you want to connect:
Github
LinkedIn
Portfolio
Top comments (0)