Testing File Uploads in React and Next.js Without a Backend: The Complete Guide
Testing File Uploads in React and Next.js Without a Backend: The Complete Guide
Handling file uploads in web applications is notoriously tricky. Unlike standard JSON payloads, file uploads rely on the browser's multipart/form-data encoding format, which introduces unique challenges: boundary parsing, chunked binary streams, file size limits, MIME type validation, and real-time upload progress indicators.
During early product development, testing file upload components is often delayed because backend storage infrastructure (such as AWS S3 presigned URLs, Google Cloud Storage buckets, or Cloudinary pipelines) is not yet configured.
In this guide, you will learn how the browser processes file uploads, discover the #1 gotcha that breaks multipart requests, build a complete drag-and-drop React upload component with real-time progress tracking, and test it live using Gen-Endpoint's free File Upload API.
Anatomy of a multipart/form-data Request
When a user selects a file through an <input type="file" />, the browser does not send plain JSON. Instead, it constructs a multi-part payload separated by a unique delimiter called a boundary:
POST /api/upload HTTP/1.1
Host: gen-endpoint.com
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Length: 34821
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="title"
Team Offsite Photo
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="offsite-2026.png"
Content-Type: image/png
[...binary byte stream of the image file...]
------WebKitFormBoundary7MA4YWxkTrZu0gW--
Notice the boundary string: ----WebKitFormBoundary7MA4YWxkTrZu0gW. The server relies on this exact string to split the incoming binary stream into separate fields and file buffers.
The #1 Developer Gotcha: Manually Setting Content-Type
The most common bug frontend developers encounter when implementing file uploads is explicitly setting the header:
// ❌ WRONG: Do NOT manually set Content-Type for FormData
fetch('https://gen-endpoint.com/api/upload', {
method: 'POST',
headers: {
'Content-Type': 'multipart/form-data', // BUG! Missing boundary delimiter!
},
body: formData,
});
Why this breaks: When you manually specify 'Content-Type': 'multipart/form-data', you omit the critical boundary=... parameter generated by the browser engine. The receiving server cannot determine where one field ends and the file starts, resulting in an immediate 400 Bad Request or hanging socket timeout.
The Fix: Let the browser set the header automatically. Simply pass the FormData instance directly to fetch or axios without specifying a Content-Type header:
// ✅ CORRECT: Browser automatically sets Content-Type with correct boundary
fetch('https://gen-endpoint.com/api/upload', {
method: 'POST',
body: formData, // Browser attaches 'Content-Type: multipart/form-data; boundary=...'
});
Building a Production-Ready Drag-and-Drop Uploader in React
Here is a full, accessible React component featuring drag-and-drop support, client-side size validation, MIME type filtering, and real-time upload progress tracking:
// components/FileUploader.tsx
'use client';
import React, { useState, useRef } from 'react';
interface UploadResult {
filename: string;
size: number;
mimeType: string;
url?: string;
}
export function FileUploader() {
const [file, setFile] = useState<File | null>(null);
const [progress, setProgress] = useState<number>(0);
const [isUploading, setIsUploading] = useState<boolean>(false);
const [isDragging, setIsDragging] = useState<boolean>(false);
const [result, setResult] = useState<UploadResult | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
const validateAndSetFile = (selectedFile: File) => {
setErrorMessage(null);
setResult(null);
setProgress(0);
if (!ALLOWED_TYPES.includes(selectedFile.type)) {
setErrorMessage(`Invalid file type: ${selectedFile.type}. Allowed: JPG, PNG, WEBP, PDF.`);
return;
}
if (selectedFile.size > MAX_FILE_SIZE) {
setErrorMessage(`File exceeds maximum limit of 5MB (${(selectedFile.size / (1024 * 1024)).toFixed(2)} MB).`);
return;
}
setFile(selectedFile);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = () => {
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
validateAndSetFile(e.dataTransfer.files[0]);
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
validateAndSetFile(e.target.files[0]);
}
};
const uploadFile = () => {
if (!file) return;
setIsUploading(true);
setProgress(0);
setErrorMessage(null);
const formData = new FormData();
formData.append('file', file);
formData.append('uploadedAt', new Date().toISOString());
// Use XMLHttpRequest to track real-time upload progress (standard fetch does not support upload progress)
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const percentage = Math.round((event.loaded / event.total) * 100);
setProgress(percentage);
}
});
xhr.addEventListener('load', () => {
setIsUploading(false);
if (xhr.status >= 200 && xhr.status < 300) {
try {
const response = JSON.parse(xhr.responseText);
setResult({
filename: file.name,
size: file.size,
mimeType: file.type,
url: response.url || 'https://gen-endpoint.com/mock-storage/' + file.name,
});
} catch {
setResult({
filename: file.name,
size: file.size,
mimeType: file.type,
});
}
} else {
setErrorMessage(`Upload failed with HTTP status ${xhr.status}`);
}
});
xhr.addEventListener('error', () => {
setIsUploading(false);
setErrorMessage('A network error occurred while uploading.');
});
xhr.open('POST', 'https://gen-endpoint.com/api/upload');
xhr.send(formData);
};
return (
<div className="max-w-xl mx-auto p-6 bg-card rounded-2xl border shadow-sm space-y-6">
<div>
<h2 className="text-xl font-bold tracking-tight text-foreground">Upload Document</h2>
<p className="text-sm text-muted-foreground mt-1">
Supports PNG, JPG, WEBP, or PDF up to 5MB.
</p>
</div>
{/* Drag & Drop Area */}
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={`p-8 border-2 border-dashed rounded-xl cursor-pointer text-center transition-colors ${
isDragging
? 'border-primary bg-primary/5'
: 'border-muted-foreground/25 hover:border-primary/50 bg-background'
}`}
>
<input
ref={fileInputRef}
type="file"
accept={ALLOWED_TYPES.join(',')}
onChange={handleFileChange}
className="hidden"
/>
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">
{file ? file.name : 'Click to select or drag and drop a file'}
</p>
<p className="text-xs text-muted-foreground">
{file ? `${(file.size / 1024).toFixed(1)} KB` : 'Maximum file size: 5 MB'}
</p>
</div>
</div>
{/* Error Feedback */}
{errorMessage && (
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-lg border border-destructive/20">
{errorMessage}
</div>
)}
{/* Upload Progress Bar */}
{isUploading && (
<div className="space-y-2">
<div className="flex justify-between text-xs font-medium text-muted-foreground">
<span>Uploading...</span>
<span>{progress}%</span>
</div>
<div className="w-full h-2 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-150"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}
{/* Action Button */}
<div className="flex justify-end gap-3">
{file && !isUploading && !result && (
<button
onClick={() => setFile(null)}
className="px-4 py-2 text-sm text-muted-foreground hover:text-foreground"
>
Clear
</button>
)}
<button
onClick={uploadFile}
disabled={!file || isUploading || !!result}
className="px-5 py-2.5 rounded-lg text-sm font-semibold bg-primary text-primary-foreground disabled:opacity-50 hover:bg-primary/90 transition-opacity"
>
{isUploading ? 'Uploading...' : 'Start Upload'}
</button>
</div>
{/* Success Receipt */}
{result && (
<div className="p-4 bg-emerald-50 dark:bg-emerald-950/20 border border-emerald-500/20 rounded-xl space-y-2">
<h4 className="text-sm font-semibold text-emerald-800 dark:text-emerald-300">
Upload Confirmed!
</h4>
<div className="text-xs text-muted-foreground space-y-1">
<p><strong>File:</strong> {result.filename}</p>
<p><strong>Size:</strong> {(result.size / 1024).toFixed(1)} KB</p>
<p><strong>MIME:</strong> {result.mimeType}</p>
</div>
</div>
)}
</div>
);
}
Testing File Uploads via cURL
You can verify upload responses instantly via terminal before running your frontend code:
# Create a temporary test file
echo "Hello Gen-Endpoint" > test-document.txt
# Upload the file using cURL (the @ symbol tells cURL to read from disk)
curl -X POST https://gen-endpoint.com/api/upload \
-F "file=@test-document.txt" \
-F "category=documentation"
Sample JSON Response:
{
"success": true,
"message": "File received and validated successfully.",
"file": {
"originalName": "test-document.txt",
"size": 19,
"mimeType": "text/plain",
"uploadedAt": "2026-11-01T12:30:00.000Z"
}
}
Simulating Edge Cases in File Uploads
When preparing for production, verify how your UI behaves under negative test conditions:
- HTTP 413 Payload Too Large: What happens when a user attempts to upload a 50MB video to an endpoint with a 5MB threshold? Test with Status API by hitting
https://gen-endpoint.com/api/status/413. - Network Interruption: Disconnect network midway through a large upload to ensure your
xhr.addEventListener('error')or Axios catch handler presents a retry action instead of hanging indefinitely. - MIME Type Spoofing: What happens if a malicious user renames an
.exebinary to.png? Ensure your server performs magic-byte header inspection rather than relying solely on file extensions.
Summary & Resources
Testing file uploads should not require spinning up AWS S3 buckets or local MinIO Docker instances during frontend prototyping. By understanding FormData boundaries and using Gen-Endpoint's File Upload API, you can build production-ready upload workflows today.
- Explore the Interactive File Upload API Documentation.
- Test request headers with our Echo API.
Ready to Test These Endpoints?
Access 23+ free mock REST APIs, test file uploads, and generate Next.js API handlers with zero signup.