DEV Community

Cover image for Handling Complex File Upload forms
Bruno Corrêa
Bruno Corrêa

Posted on

Handling Complex File Upload forms

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
}>();
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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);
  });
};
Enter fullscreen mode Exit fullscreen mode

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)
  });
}
Enter fullscreen mode Exit fullscreen mode

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"
})}
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode

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

Demo example

My complete example repo shows both approaches side-by-side with both the frontend and backend ready to go in one app:

Vercel deploy

git clone https://github.com/brinobruno/file-upload-poc-next
cd file-upload-poc-next
Enter fullscreen mode Exit fullscreen mode

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)