Avatar Upload in React: Crop, Preview, S3 Upload Flow
Build a full avatar upload flow in React: drag-and-drop file input, client-side crop with react-image-crop, live preview, and S3 presigned URL upload in under 200 lines.
Why Most Avatar Upload Implementations Are a Mess
Honestly, avatar upload is one of those features that looks simple until you're three hours deep in canvas API edge cases and S3 CORS errors. Every SaaS app needs it. Almost nobody builds it cleanly the first time.
The typical path goes like this: you wire up a plain <input type="file">, shove the file straight to an API route, store a URL in the database, and call it done. Then six months later you're dealing with 8MB profile photos making your UI sluggish, users uploading portrait photos that render as tiny slivers in a circular frame, and a database column full of inconsistent file formats.
This article walks through the proper flow: drag-and-drop input with a visual drop zone, client-side crop using react-image-crop v10.x, a canvas-based preview, and upload via a presigned S3 URL so you never proxy binary data through your server. It's not magic — it's just the right order of operations.
The component we're building pairs well with other interactive UI pieces. If you're also working on profile settings pages, check out animated tabs for React — tabs are a natural container for avatar upload alongside notification preferences and account details.
Setting Up the File Drop Zone Component
Start with the drop zone. You want two entry points: a click-to-browse and a drag-and-drop target. Both should end up feeding the same state. Keep them unified from the start or you'll end up with two separate code paths that slowly diverge.
Here's a minimal drop zone built with Tailwind v4.0.2 that handles both interactions. The isDragOver state drives a visual highlight — rgba(255,255,255,0.08) background on drag enter, back to transparent on drag leave.
import { useRef, useState, DragEvent, ChangeEvent } from 'react';
interface DropZoneProps {
onFileSelect: (file: File) => void;
}
export function AvatarDropZone({ onFileSelect }: DropZoneProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [isDragOver, setIsDragOver] = useState(false);
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragOver(false);
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) onFileSelect(file);
};
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) onFileSelect(file);
};
return (
<div
onClick={() => inputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setIsDragOver(true); }}
onDragLeave={() => setIsDragOver(false)}
onDrop={handleDrop}
className="relative flex flex-col items-center justify-center w-48 h-48 rounded-full border-2 border-dashed cursor-pointer transition-colors duration-200"
style={{
borderColor: isDragOver ? '#6366f1' : '#374151',
backgroundColor: isDragOver ? 'rgba(99,102,241,0.08)' : 'transparent',
}}
>
<span className="text-sm text-gray-400 select-none">Drop photo here</span>
<input
ref={inputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleChange}
/>
</div>
);
}One thing worth noting: always check file.type.startsWith('image/') before doing anything else. Users will occasionally drop a PDF or a .webp file with a renamed .jpg extension — the MIME type check catches most of that. You can add explicit format validation later if you need to restrict to JPEG and PNG specifically.
Client-Side Crop with react-image-crop
Once you have the file, create an object URL with URL.createObjectURL(file) and hand it to react-image-crop. Install version 10.x: npm i react-image-crop@10. The API changed significantly between v9 and v10 — the crop state shape is different, so older blog posts you find will likely give you TypeScript errors.
Set aspect={1} for a square crop. For circular avatars that's exactly what you want. Set a minimum size too — minWidth={80} and minHeight={80} prevents users from selecting a 2x2 pixel region that'll look like colored noise when stretched to 40px in the nav.
The onComplete callback gives you a PixelCrop object with x, y, width, and height in actual image pixels (not percentages). Store that in state. You'll use it when drawing to canvas. Don't try to convert or scale it yourself at this stage — the canvas drawing step handles that.
Also remember to call URL.revokeObjectURL(imgSrc) in a useEffect cleanup. Object URLs hold a reference to the file in memory. If a user opens the cropper, closes it, and opens a new file, you'll leak the old reference without the cleanup.
Canvas Preview: Converting the Crop to a Blob
The crop step gives you coordinates. The canvas step gives you actual image data. This is where you draw the cropped region at a fixed output size — 256x256 pixels is a solid default for avatars. Large enough for retina displays at typical UI sizes, small enough that the resulting file is 20-40KB as a JPEG.
const AVATAR_SIZE = 256;
export async function cropImageToBlob(
image: HTMLImageElement,
crop: PixelCrop
): Promise<Blob> {
const canvas = document.createElement('canvas');
canvas.width = AVATAR_SIZE;
canvas.height = AVATAR_SIZE;
const ctx = canvas.getContext('2d')!;
// Scale factor between the displayed image and its natural size
const scaleX = image.naturalWidth / image.width;
const scaleY = image.naturalHeight / image.height;
ctx.drawImage(
image,
crop.x * scaleX,
crop.y * scaleY,
crop.width * scaleX,
crop.height * scaleY,
0,
0,
AVATAR_SIZE,
AVATAR_SIZE
);
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error('Canvas toBlob failed'))),
'image/jpeg',
0.88
);
});
}Quality 0.88 hits the sweet spot between file size and visual fidelity at 256px. Going above 0.92 gains almost nothing perceptible at avatar sizes and inflates the file. Below 0.80 you'll see compression artifacts on faces.
Why is this approach better than server-side crop? Because the user sees exactly what they'll get before anything leaves their device. No round-trip, no waiting. And you upload a small pre-cropped JPEG instead of the original 4MB camera photo.
Presigned S3 URL Upload: Skip the Server Proxy
Here's where a lot of implementations go wrong. They upload the file to their own API route, then the API route uploads to S3. You're paying twice for the bandwidth, adding latency, and putting binary file data through your application server. Don't do that.
The correct approach: your API generates a presigned PUT URL and returns it to the client. The client uploads directly to S3 using that URL. Your server never sees the file bytes. The API route just needs to verify the user session and generate the URL — that's a 50ms response instead of a multi-second file upload.
On the Next.js API side (simplified): call s3.createPresignedPost or getSignedUrl with PutObjectCommand. Return the URL and the final object key to the client. The client does fetch(presignedUrl, { method: 'PUT', body: blob, headers: { 'Content-Type': 'image/jpeg' } }). After the upload succeeds, call your API again to save the final CDN URL to the user's profile in the database.
Set your S3 bucket CORS to allow PUT from your domain. This trips up every developer the first time. The CORS config needs to explicitly list PUT in AllowedMethods, not just GET and POST. Also set CacheControl: 'public, max-age=31536000, immutable' on the object — avatars don't change at the same URL, so you can cache them aggressively.
Circular Preview and Loading States
The visual layer matters. A crop tool with no feedback feels broken even when it works perfectly. Show a circular preview of the cropped result before the user confirms the upload. This is just an <img> tag with border-radius: 50% and the object URL from the canvas blob — URL.createObjectURL(croppedBlob).
During upload, replace the preview image with a spinner overlay at the same 96px or 128px size. An opacity-50 dimmed avatar with a centered spinning ring reads as 'uploading' without any text needed. Keep the exact dimensions stable so the surrounding layout doesn't jump. This is the 8px gap rule in practice — your avatar container should have a fixed w-24 h-24 or w-32 h-32 that doesn't flex during state changes.
If you're building this into a profile card or settings panel, cards stack in React shows patterns for composing these contained UI blocks cleanly. The avatar upload naturally lives inside a card container.
Handle errors visibly. If the S3 upload fails — network drop, expired presigned URL, CORS misconfiguration — show an inline error message inside the upload zone. Don't just log it to the console and leave the user staring at a spinner. A small red border on the drop zone and a one-line message is enough.
Wiring It All Together in One Component
The full component state machine has four stages: idle (showing the drop zone), cropping (showing the crop tool), preview (showing the cropped result with confirm/cancel), and uploading (showing the spinner). Each stage renders a different UI. Each transition is triggered by user action or async resolution.
Keep all four stages in a single AvatarUpload component rather than splitting them across separate components that pass callbacks up three levels. The internal state is tightly coupled — the file URL, the crop coordinates, the blob, the upload progress — and spreading it across components will make it fragile. One component, one useReducer, clean action types.
The final confirm button calls cropImageToBlob, gets the presigned URL, uploads, then calls onSuccess(newAvatarUrl) back to the parent. The parent is responsible for updating the user's profile display. This keeps the upload component generic and reusable across different profile pages. Speaking of reusable interactive components, if you need to add a theme toggle to React alongside your profile settings, the same single-responsibility pattern applies there too.
Is it worth adding client-side file size validation before showing the crop tool? Yes, absolutely. A 15MB RAW file from a DSLR will cause the canvas operations to be slow on low-end devices. Reject anything over 10MB with a clear error message before you ever create the object URL.
Accessibility and Mobile Considerations
Drop zones have a real accessibility problem: keyboard users can't drop files. Make sure the drop zone also works as a button — role="button", tabIndex={0}, and a keydown handler for Enter and Space that triggers the hidden file input click. Screen readers should announce it as 'Upload profile photo, button'.
On mobile, the drag-and-drop path is irrelevant — nobody drags files on a phone. But the tap-to-browse flow works fine if you set accept="image/*". iOS Safari will offer the camera roll and camera directly. Android Chrome does the same. Don't add any mobile-specific code for the input — the browser handles it.
The crop interaction is trickier on touch. react-image-crop v10 has touch support built in, but the minimum crop handle size matters. The default handle size can be too small for thumbs on 375px screens. You can override the handle CSS: set .ReactCrop__drag-handle to at least 24px x 24px with appropriate touch-action properties. Test on an actual phone, not just Chrome DevTools mobile simulation.
Finally, alt text on the preview image. Once uploaded, the <img> showing the user's avatar needs a descriptive alt — typically alt="${userName}'s profile photo". An empty alt is wrong here since the image is meaningful content, not decorative.
FAQ
Yes, but mark the component with 'use client' — react-image-crop uses browser APIs (canvas, FileReader) and won't work in server components. Import it dynamically with next/dynamic and ssr: false if you hit hydration issues.
Use the AWS SDK v3: import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3' and import { getSignedUrl } from '@aws-sdk/s3-request-presigner'. Call getSignedUrl(s3, new PutObjectCommand({ Bucket, Key, ContentType: 'image/jpeg' }), { expiresIn: 300 }). Return the URL to the client.
Your bucket CORS config needs AllowedOrigins with your domain, AllowedMethods including PUT (not just GET/POST), AllowedHeaders with ['*'] or at minimum Content-Type, and ExposeHeaders with ETag. Missing PUT in AllowedMethods is the most common cause of 403 errors on direct upload.
Check both file.type and file extension. Set accept="image/jpeg,image/png" on the input element. Then validate file.type === 'image/jpeg' || file.type === 'image/png' before processing. Don't rely on the file extension alone — it can be faked.
You can clip the canvas context to a circle before drawing: ctx.beginPath(); ctx.arc(128, 128, 128, 0, Math.PI * 2); ctx.clip(); — then draw the image. But this only creates a circular shape in PNG with transparency (use 'image/png' in toBlob). For JPEG the background will be white. Most UIs apply border-radius: 50% in CSS instead and store a square JPEG.
You're probably drawing to a canvas at display size (e.g. 96x96) instead of output size. Always draw to a canvas of at least 256x256 regardless of how it's displayed in the UI. Then let CSS scale it down. Upscaling a 96px canvas to 256px display will look blurry — it's the wrong direction.