Best File Upload Libraries for React in 2026
TL;DR
React Dropzone for the UI component; UploadThing for full-stack upload management. React Dropzone (~5M weekly downloads) is the headless file drop zone — handles drag-and-drop, file validation, and preview. UploadThing (~300K, growing fast) is a full-stack upload service from the T3 stack team — handles S3 uploads, file type validation, size limits, and webhooks. Filepond (~500K) is the full-featured upload widget with chunking, resumable uploads, and plugins.
Key Takeaways
- React Dropzone: ~5M weekly downloads — headless, composable, pairs with any backend
- UploadThing: ~300K downloads — T3 stack, managed S3, type-safe Next.js integration
- Filepond: ~500K downloads — full widget, resumable chunks, image preview/transform
- react-dropzone + presigned S3 URLs — most flexible for custom backends
- UploadThing — free tier: 2GB storage, $25/10GB
React Dropzone: Headless and Composable
React Dropzone is the most-downloaded file upload library in the React ecosystem, and it earns that position by doing exactly one thing well: turning any DOM element into a drag-and-drop file target with validation. It handles the hard parts of file input UX — drag state management, focus handling, keyboard accessibility, MIME type validation, file size limits, and multi-file queuing — while giving you complete control over the visual layer.
The entire API lives in the useDropzone() hook. Call it with your options and destructure the event handlers (getRootProps, getInputProps) and state flags (isDragActive, isDragReject, acceptedFiles, fileRejections). Spread those props onto your container div and input element, and the drag-and-drop behavior is live.
React Dropzone deliberately does not handle the actual upload. It gives you the File objects; what you do with them is your concern. That's the right separation of concerns for a library — it means React Dropzone works with S3 presigned URLs, your own API, Cloudinary, or any other destination without configuration.
// npm install react-dropzone
import { useDropzone } from 'react-dropzone';
import { useState, useCallback } from 'react';
interface UploadedFile {
file: File;
preview: string;
status: 'pending' | 'uploading' | 'done' | 'error';
progress: number;
}
function ImageUploader({ onUpload }: { onUpload: (urls: string[]) => void }) {
const [files, setFiles] = useState<UploadedFile[]>([]);
const onDrop = useCallback((acceptedFiles: File[]) => {
const newFiles = acceptedFiles.map(file => ({
file,
preview: URL.createObjectURL(file),
status: 'pending' as const,
progress: 0,
}));
setFiles(prev => [...prev, ...newFiles]);
uploadFiles(newFiles);
}, []);
const { getRootProps, getInputProps, isDragActive, isDragReject } = useDropzone({
onDrop,
accept: { 'image/*': ['.jpg', '.jpeg', '.png', '.webp', '.gif'] },
maxSize: 10 * 1024 * 1024, // 10MB
maxFiles: 5,
onDropRejected: (rejections) => {
rejections.forEach(({ file, errors }) => {
console.error(`${file.name}: ${errors.map(e => e.message).join(', ')}`);
});
},
});
const uploadFiles = async (filesToUpload: UploadedFile[]) => {
for (const fileData of filesToUpload) {
setFiles(prev => prev.map(f =>
f.file === fileData.file ? { ...f, status: 'uploading' } : f
));
// Get presigned URL from your API
const { url, key } = await getPresignedUrl(fileData.file.name, fileData.file.type);
// Upload directly to S3
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (e) => {
const progress = Math.round((e.loaded / e.total) * 100);
setFiles(prev => prev.map(f =>
f.file === fileData.file ? { ...f, progress } : f
));
};
await new Promise<void>((resolve, reject) => {
xhr.onload = () => {
setFiles(prev => prev.map(f =>
f.file === fileData.file ? { ...f, status: 'done', progress: 100 } : f
));
resolve();
};
xhr.onerror = reject;
xhr.open('PUT', url);
xhr.setRequestHeader('Content-Type', fileData.file.type);
xhr.send(fileData.file);
});
}
};
return (
<div>
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${
isDragActive ? 'border-blue-500 bg-blue-50' :
isDragReject ? 'border-red-500 bg-red-50' :
'border-gray-300 hover:border-gray-400'
}`}
>
<input {...getInputProps()} />
{isDragActive ? (
<p>Drop files here...</p>
) : (
<p>Drag & drop images here, or click to select (max 5 files, 10MB each)</p>
)}
</div>
{files.length > 0 && (
<div className="mt-4 grid grid-cols-3 gap-4">
{files.map((fileData, i) => (
<div key={i} className="relative">
<img src={fileData.preview} className="w-full h-32 object-cover rounded" />
{fileData.status === 'uploading' && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center rounded">
<span className="text-white text-sm">{fileData.progress}%</span>
</div>
)}
{fileData.status === 'done' && (
<div className="absolute top-2 right-2 bg-green-500 text-white rounded-full w-6 h-6 flex items-center justify-center">
✓
</div>
)}
</div>
))}
</div>
)}
</div>
);
}
The preview URL approach shown above — URL.createObjectURL(file) — creates a local blob URL that renders immediately without waiting for the upload to complete. This gives users instant visual feedback. Remember to call URL.revokeObjectURL() when components unmount to avoid memory leaks.
UploadThing: Full-Stack for Next.js
UploadThing emerged from the T3 stack ecosystem and solves a real problem: the upload-to-S3 flow involves several non-trivial pieces — presigned URL generation, auth checks, file type validation server-side, webhook callbacks on completion — and setting this up from scratch in every project is tedious. UploadThing bundles this into a type-safe file router API for Next.js (with adapters for other frameworks).
The server side defines "file routes" — named upload configurations with middleware for auth checks, file type constraints, size limits, and callbacks. The client side uses the generated useUploadThing() hook or pre-built UploadButton/UploadDropzone components that are typed to your specific file router. TypeScript enforces that you're calling the right endpoint with valid options at compile time.
// UploadThing — server: define upload routes
// app/api/uploadthing/core.ts
import { createUploadthing, type FileRouter } from 'uploadthing/next';
import { auth } from '@/auth';
const f = createUploadthing();
export const ourFileRouter = {
// Route 1: Profile image (authenticated, max 4MB)
profileImage: f({ image: { maxFileSize: '4MB', maxFileCount: 1 } })
.middleware(async ({ req }) => {
const session = await auth();
if (!session) throw new Error('Unauthorized');
return { userId: session.user.id };
})
.onUploadComplete(async ({ metadata, file }) => {
await updateUserAvatar(metadata.userId, file.url);
return { uploadedBy: metadata.userId };
}),
// Route 2: Document upload (authenticated, multiple files)
documentUpload: f({
pdf: { maxFileSize: '32MB' },
image: { maxFileSize: '8MB' },
})
.input(z.object({ projectId: z.string() }))
.middleware(async ({ req, input }) => {
const session = await auth();
if (!session) throw new Error('Unauthorized');
return { userId: session.user.id, projectId: input.projectId };
})
.onUploadComplete(async ({ metadata, file }) => {
await saveDocument(metadata.userId, metadata.projectId, file.url);
}),
} satisfies FileRouter;
export type OurFileRouter = typeof ourFileRouter;
// UploadThing — server: route handler
// app/api/uploadthing/route.ts
import { createRouteHandler } from 'uploadthing/next';
import { ourFileRouter } from './core';
export const { GET, POST } = createRouteHandler({ router: ourFileRouter });
// UploadThing — client: pre-built React components
import { UploadButton, UploadDropzone } from '@uploadthing/react';
import type { OurFileRouter } from '@/app/api/uploadthing/core';
function ProfileImageUpload({ userId }) {
return (
<UploadButton<OurFileRouter, 'profileImage'>
endpoint="profileImage"
onClientUploadComplete={(res) => {
console.log('Uploaded:', res);
toast.success('Profile photo updated!');
}}
onUploadError={(error) => {
toast.error(`Upload failed: ${error.message}`);
}}
/>
);
}
// Full drag-and-drop zone
function DocumentUploader({ projectId }) {
return (
<UploadDropzone<OurFileRouter, 'documentUpload'>
endpoint="documentUpload"
input={{ projectId }}
onClientUploadComplete={(res) => {
console.log('Files uploaded:', res.map(r => r.url));
}}
/>
);
}
UploadThing handles chunked uploads, progress tracking, and retry logic internally. The managed service stores files on UploadThing's CDN with a free tier of 2GB and paid plans starting at $25/month for 10GB. You can also configure it to use your own S3 bucket.
The main trade-off is vendor dependency. UploadThing's type-safe API and pre-built components work beautifully for standard Next.js apps, but if you need to migrate away from UploadThing later or use a non-Next.js framework, the integration is tighter to unwind.
Filepond: The Full-Featured Widget
Filepond (~500K downloads) is the library to reach for when you need a complete upload widget rather than a headless hook. It ships with a file queue UI, drag-and-drop, progress indicators, error states, and a plugin architecture for adding behavior like image cropping, EXIF orientation correction, AVIF/HEIC conversion, and file validation.
The bundle is larger than the headless alternatives (~50KB vs ~10KB for React Dropzone), but that includes the UI components, animation, and plugin infrastructure. If you were going to build those things yourself with React Dropzone, you'd end up at a similar weight anyway.
The react-filepond integration wraps the core FilePond library in a React component:
// npm install react-filepond filepond filepond-plugin-image-preview filepond-plugin-image-transform
import { FilePond, registerPlugin } from 'react-filepond';
import FilePondPluginImagePreview from 'filepond-plugin-image-preview';
import FilePondPluginImageTransform from 'filepond-plugin-image-transform';
import FilePondPluginFileValidateSize from 'filepond-plugin-file-validate-size';
import 'filepond/dist/filepond.min.css';
import 'filepond-plugin-image-preview/dist/filepond-plugin-image-preview.css';
// Register plugins
registerPlugin(
FilePondPluginImagePreview,
FilePondPluginImageTransform,
FilePondPluginFileValidateSize
);
function DocumentUploader({ onUploadComplete }) {
const [files, setFiles] = useState([]);
return (
<FilePond
files={files}
onupdatefiles={setFiles}
allowMultiple={true}
maxFiles={10}
maxFileSize="50MB"
server={{
// Chunked upload endpoint — FilePond handles the chunking protocol
process: '/api/upload',
revert: '/api/upload/revert',
restore: '/api/upload/restore',
load: '/api/upload/load',
}}
chunkUploads={true}
chunkSize={5 * 1024 * 1024} // 5MB chunks
labelIdle='Drag & drop files or <span class="filepond--label-action">Browse</span>'
imagePreviewHeight={200}
// Resize images before upload
imageTransformOutputMimeType="image/webp"
imageTransformOutputQuality={85}
/>
);
}
Filepond's chunk upload protocol is particularly strong. For large files, it breaks uploads into configurable chunks (5MB in the example above) and supports resuming interrupted uploads via a restore endpoint. This is essential for video uploads or large documents where network interruptions are likely.
The plugin system makes Filepond a complete pipeline: filepond-plugin-image-transform can resize and compress images client-side before they ever leave the browser, reducing bandwidth significantly for image-heavy applications.
S3 Direct Upload Pattern
Regardless of which library you use for the UI, the most efficient upload architecture for S3 is the presigned URL pattern. Your server never receives the file bytes — it only generates a short-lived signed URL that grants the browser permission to upload directly to S3.
// API route: generate presigned S3 URL
// app/api/upload/presign/route.ts
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const s3 = new S3Client({ region: process.env.AWS_REGION! });
export async function POST(request: Request) {
const { filename, contentType, size } = await request.json();
// Validate before generating presigned URL
if (size > 50 * 1024 * 1024) {
return Response.json({ error: 'File too large' }, { status: 400 });
}
const key = `uploads/${Date.now()}-${filename}`;
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
ContentType: contentType,
ContentLength: size,
});
const url = await getSignedUrl(s3, command, { expiresIn: 300 }); // 5 min
return Response.json({ url, key });
}
// Client: upload using presigned URL
async function uploadToS3(file: File): Promise<string> {
// 1. Get presigned URL from your API
const { url, key } = await fetch('/api/upload/presign', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: file.name,
contentType: file.type,
size: file.size,
}),
}).then(r => r.json());
// 2. Upload directly to S3 (bypasses your server)
await fetch(url, {
method: 'PUT',
body: file,
headers: { 'Content-Type': file.type },
});
// 3. Return the public CDN URL
return `https://${process.env.NEXT_PUBLIC_S3_BUCKET}.s3.amazonaws.com/${key}`;
}
This pattern works with any frontend upload library. React Dropzone gives you the File objects; the presigned URL function handles the actual transfer. Your server stays lean and your bandwidth costs stay low.
Package Health Table
| Library | Weekly Downloads | Approach | Chunked Uploads | Resumable | Bundle Size | S3 Support |
|---|---|---|---|---|---|---|
| React Dropzone | ~5M | Headless hook | No (DIY) | No (DIY) | ~10KB | Via presigned URL |
| UploadThing | ~300K | Full-stack managed | Yes | Partial | ~25KB | Managed + custom |
| Filepond | ~500K | Widget + plugins | Yes | Yes | ~50KB | Via server route |
When to Choose
Choose React Dropzone when you have a custom design system and need full control over the upload UI. It pairs with any backend — your own API, S3 presigned URLs, Cloudinary, or Vercel Blob. The headless approach means zero style conflicts and maximum flexibility. Most teams building polished products use React Dropzone for the drag-and-drop logic and wire their own upload infrastructure.
Choose UploadThing when you're building with Next.js and want to eliminate the S3 setup boilerplate. The file router pattern is clean, the TypeScript integration is excellent, and the pre-built components handle the common cases quickly. It's the obvious choice for T3 stack projects and any Next.js app where getting uploads working fast outweighs maximum customization.
Choose Filepond when you need chunked or resumable uploads for large files, or when you want image transformation (resize, compress, format conversion) to happen client-side before the upload begins. The plugin ecosystem covers most advanced scenarios, and the built-in widget UI is polished enough for many products without customization.
Common Upload Patterns and Pitfalls
File Type Validation: Client vs Server
Every upload implementation needs file type validation, but client-side validation alone is not secure. The accept prop on React Dropzone or UploadThing's maxFileSize configuration validates at the UI layer, which prevents users from accidentally uploading the wrong format. But a malicious user can bypass client-side checks by sending a raw HTTP request directly to your upload endpoint.
Always validate file type on the server side as well. For UploadThing, the server-side file route definition enforces types before the presigned URL is generated. For custom implementations with React Dropzone, your presigned URL endpoint should check the contentType parameter against an allowlist before signing.
Checking the file extension alone is insufficient — check the MIME type. Even MIME type checking can be spoofed on the client, so for security-sensitive uploads (user profile images, legal documents) consider running a magic number check server-side using a library like file-type that reads the actual binary header bytes.
Progress Tracking
React Dropzone doesn't track progress natively — it gives you the File object and you handle the upload. The example above uses XMLHttpRequest with xhr.upload.onprogress for granular byte-level progress. If you're using fetch() instead, progress tracking requires the ReadableStream API, which is more complex. XHR remains the simpler choice for progress tracking.
UploadThing tracks progress internally and exposes it through the onUploadProgress callback on both useUploadThing and the pre-built components. For most applications, this built-in progress reporting is sufficient and avoids the XHR boilerplate.
Filepond tracks progress per-chunk for chunked uploads, which gives accurate progress reporting even for very large files where a single upload request might take minutes.
Handling Upload Failures and Retries
Network interruptions during uploads are common, especially on mobile connections. React Dropzone doesn't provide retry logic — you build it into your uploadFiles function. A simple approach wraps the upload in a retry loop with exponential backoff:
async function uploadWithRetry(file: File, presignedUrl: string, maxAttempts = 3) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
await fetch(presignedUrl, {
method: 'PUT',
body: file,
headers: { 'Content-Type': file.type },
});
return; // success
} catch (error) {
if (attempt === maxAttempts) throw error;
await new Promise(r => setTimeout(r, 1000 * attempt)); // exponential backoff
}
}
}
UploadThing handles retries internally. Filepond with the chunk plugin supports resumable uploads via the tus protocol — interrupted uploads can be resumed from where they stopped rather than restarted from scratch, which is essential for large file uploads.
Image Optimization Before Upload
Uploading 12MB camera photos when you only need a 1MB image is a common waste of bandwidth and storage. Client-side image resizing before upload significantly reduces both. The browser-image-compression library handles this cleanly:
import imageCompression from 'browser-image-compression';
const compressedFile = await imageCompression(originalFile, {
maxSizeMB: 1,
maxWidthOrHeight: 1920,
useWebWorker: true,
});
// Upload compressedFile instead of originalFile
Filepond's filepond-plugin-image-transform does this automatically as part of its plugin pipeline — you configure the target dimensions and quality, and Filepond compresses before sending.
Multi-Part Forms with Files
When uploading files alongside other form data, there are two approaches. The simpler option is to upload the file first, get back a URL, then submit the form with that URL as a string field. This is what UploadThing's onUploadComplete callback facilitates — by the time the form submits, the file is already uploaded and you just submit the URL.
The alternative is using multipart/form-data to send the file and other fields in a single request. This works for small files but is less suitable for large uploads because the entire request waits for the file to finish before the server processes anything. The two-step approach (upload file, then submit form with URL) is generally more robust.
- How email and file delivery compare: How to Add Email Sending in Node.js
- Package health: react-dropzone on PkgPulse
- Package health: uploadthing on PkgPulse
See the live comparison
View uploadthing vs. react dropzone on PkgPulse →