React Image Upload: The Component, and the Resize After It
A controlled file input with preview and validation, the object-URL leak it hides, and the build-time resize that cut 110 covers from 15,102 KiB to 3,570 KiB.
Uploading an image from React is about fifteen lines. Every tutorial stops there, which is why the same three bugs keep shipping: a preview that leaks memory, validation that only exists in the browser, and a 2120-pixel photograph being painted into a 530-pixel card. This post covers the component, then the parts that come after it — measured on a storefront that serves 110 product covers.
The short answer
Hold the File in state, build a preview with URL.createObjectURL, revoke it when the component unmounts or the file changes, validate type and size on both sides of the wire, and upload the bytes straight to storage rather than through your own server. Then resize at build or ingest time — an uploaded image is almost never a renderable one.
The component
A controlled file input, a preview, and an upload. Nothing here needs a library:
"use client";
import { useEffect, useState } from "react";
const MAX_BYTES = 5 * 1024 * 1024;
const ACCEPTED = ["image/jpeg", "image/png", "image/webp"];
export default function ImageUpload() {
const [file, setFile] = useState<File | null>(null);
const [preview, setPreview] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
// One object URL per file, revoked when the file changes or we unmount.
useEffect(() => {
if (!file) return;
const url = URL.createObjectURL(file);
setPreview(url);
return () => URL.revokeObjectURL(url);
}, [file]);
function pick(event: React.ChangeEvent<HTMLInputElement>) {
const next = event.target.files?.[0] ?? null;
setError(null);
if (!next) return setFile(null);
if (!ACCEPTED.includes(next.type)) return setError("JPEG, PNG or WebP only.");
if (next.size > MAX_BYTES) return setError("Keep it under 5 MB.");
setFile(next);
}
async function upload() {
if (!file) return;
setBusy(true);
try {
const body = new FormData();
body.append("image", file);
const res = await fetch("/api/upload", { method: "POST", body });
if (!res.ok) throw new Error(await res.text());
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Upload failed.");
} finally {
setBusy(false);
}
}
return (
<div>
<label htmlFor="image">Product image</label>
<input
id="image"
type="file"
accept={ACCEPTED.join(",")}
onChange={pick}
/>
{preview && <img src={preview} alt="" width={320} />}
{error && <p role="alert">{error}</p>}
<button type="button" onClick={upload} disabled={!file || busy}>
{busy ? "Uploading…" : "Upload"}
</button>
</div>
);
}
Four details in there are the difference between this and the version that ships bugs.
Never set value on a file input. It is one of the two inputs React cannot control; assigning anything but an empty string throws. To clear a selection, reset the form or remount the input with a key.
Do not read event.target.files asynchronously. By the time an await resolves, React may have pooled or the input may have been reset. Copy the File into state on the first synchronous line, as above.
Give the preview an empty alt. It is a thumbnail of a file the user just chose — they know what it is. An empty alt on a decorative image is correct; inventing alt="preview" adds noise to a screen reader for no information.
accept is a convenience, not a control. It filters the file picker. It does not stop a drag-and-drop, and it certainly does not stop a crafted request.
The leak every preview tutorial ships
URL.createObjectURL registers a blob in the document and keeps it alive until you revoke it or the page unloads. The common shape:
// Leaks one blob URL per selection, for the life of the page.
<img src={URL.createObjectURL(file)} />
That call runs on every render, mints a fresh URL each time, and never releases any of them. Pick a dozen 4 MB photos in a session and you are holding ~50 MB of blobs the garbage collector cannot touch, because the document still references them. The useEffect cleanup in the component above is the entire fix: one URL per file, revoked when the dependency changes.
Multiple images
For a gallery rather than a single picture, the state shape matters more than the input does. Keep an array of records, not an array of Files, so each item carries its own preview URL and error:
type Pending = { id: string; file: File; url: string };
const [items, setItems] = useState<Pending[]>([]);
function add(event: React.ChangeEvent<HTMLInputElement>) {
const picked = Array.from(event.target.files ?? []);
setItems((prev) => [
...prev,
...picked.map((file) => ({
id: crypto.randomUUID(),
file,
url: URL.createObjectURL(file),
})),
]);
}
function remove(id: string) {
setItems((prev) => {
const gone = prev.find((item) => item.id === id);
if (gone) URL.revokeObjectURL(gone.url);
return prev.filter((item) => item.id !== id);
});
}
Two reasons for the generated id. It is a stable React key — array index keys reorder previews when one is removed mid-list — and it gives you something to correlate an upload result against when four requests finish out of order. Revoke on removal and in an unmount effect; the single-file cleanup above does not cover a list.
Add multiple to the input and that is the whole feature. A drag-and-drop zone is the same handler reading event.dataTransfer.files instead, with onDragOver calling preventDefault so the browser does not navigate to the dropped file.
Where the bytes should actually go
The component above posts to your own server, which is the easiest thing to write and the first thing to hit a wall:
| Path | Bytes pass through | Practical ceiling | Good for |
|---|---|---|---|
| Server Action | Your function | ~1 MB default action body limit | Small files attached to a form |
| Route Handler | Your function | Platform request limit — 4.5 MB on Vercel serverless | Custom headers, streaming |
| Presigned direct-to-storage | Nothing — browser to storage | The storage provider's limit | Anything user-facing, anything large |
Photographs from a phone clear 4.5 MB routinely, so for real image upload the third row is the default and the other two are the exception. Your server's job becomes issuing permission rather than carrying data: authorize the request, return a signed upload URL, let the browser PUT to storage. The full comparison of those paths, including the body-limit arithmetic, is in Next.js file upload.
Whichever row you pick: validate on the server too. The size and type checks in the component are a user-experience affordance. The control is the one that runs where the client cannot reach it.
The upload this codebase actually runs
Honesty first, because it changes how you should read the rest: this storefront ships zero file inputs. grep -rn 'type="file"' src/ returns nothing. Every image it serves was produced by a script and uploaded out of band, so the component above is a pattern we are confident in, not code copied from this repo.
What is copied from this repo is the storage half, which is the half that tends to be hand-waved. scripts/release/upload.ts puts release zips into a private bucket with a service-role client:
const admin = createClient(url!, key!, {
auth: { persistSession: false, autoRefreshToken: false },
});
const { error } = await admin.storage
.from("releases")
.upload(objectPath, readFileSync(localZip), {
upsert: true,
contentType: "application/zip",
});
contentType is set explicitly rather than inferred, and upsert: true makes a re-run idempotent instead of a duplicate. The read path, in src/app/api/download/route.ts, authorizes and then mints a short-lived signed URL — the object is never reachable by guessing its path. Point an image upload at the same shape and the uploaded file is private by default, which is the posture you want for anything a user supplies.
What the file has to become
Here is the part no "React image upload" tutorial mentions, and it is the one with numbers behind it.
An uploaded image is not a renderable image. This catalog's 110 covers are 2120×1325 JPEGs because they double as og:image and as the Product schema's image, where a large universally-decodable file is correct. The same file in a product card is painted at 530×330. Serving the original there means shipping roughly 137 KiB to deliver about 33 KiB worth of pixels.
So every rendered <img> points at a pre-built derivative instead. scripts/images/build-variants.ts runs sharp over the catalog:
await sharp(srcPath)
.resize({ width, withoutEnlargement: true })
.webp({ quality: QUALITY, effort: 5 })
.toFile(outPath);
Two widths, declared once in src/lib/imageVariants.ts: CARD_VARIANT_WIDTH = 1060 (530 CSS pixels at DPR 2) and VIEW_VARIANT_WIDTH = 1600. Quality is pinned at 78, which is where these screenshots stop paying for quality — below it the flat UI fills start banding, above it the file grows with no visible return.
Measured across all 110 covers in this repository, just now:
| Set | Total | Average per cover |
|---|---|---|
| Source JPEG | 15,102 KiB | 137 KiB |
-card.webp (1060w) | 3,570 KiB | 32 KiB |
-view.webp (1600w) | 4,637 KiB | 42 KiB |
76% fewer bytes for the card variant, and the source file is untouched for the surfaces that need it. A single product: cover.jpg 136,457 bytes → cover-card.webp 28,200 bytes. The 440 carousel slides, which carry no SEO duty at all, were converted to WebP outright by the same script.
One more defect worth inheriting. After all that, /templates was still slow, because the first card's image was loading="lazy" — the browser could not discover the page's largest element until layout ran, costing 1.67 seconds of load delay. The fix is one prop: the first card of a page-opening grid sets priority, every other card stays lazy. If you accept user uploads and render them in a grid, that first image is your Largest Contentful Paint, and lazy-loading it is a measurable own goal.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Cannot set property value of #<HTMLInputElement> | Tried to control a file input with value | Use an uncontrolled input; reset by remounting with a key |
Preview is blank after an await | Read event.target.files after the handler yielded | Copy the File into state synchronously, first line |
| Memory climbs as the user picks files | Object URLs never revoked | Revoke in an effect cleanup, and on removal from a list |
| Previews jump when one image is removed | Array index used as the React key | Generate a stable id per item |
| 413 on upload in production, fine locally | Serverless request body limit | Presigned direct-to-storage upload |
| Upload works, the file is corrupt | Content-Type wrong, or the body re-encoded | Send the File as-is in FormData, or PUT the raw blob with its type |
| Upload succeeds but the image 404s | The bucket is private and the URL is not signed | Issue a signed URL after an authorization check; do not make the bucket public |
| EXIF-rotated photos appear sideways | Browsers honour EXIF orientation, your resizer may not | Normalise orientation server-side (sharp's rotate() with no argument) |
| Page is slow despite small files | The grid's first image is lazy-loaded | Mark the first image priority/eager; keep the rest lazy |
Frequently asked questions
Do I need a library like react-images-uploading or react-dropzone?
No, for the common case. A file input plus the effect above is the whole feature, and it is less code than configuring a library. Reach for one when you want chunked or resumable uploads, retry with backoff, or a polished drag-and-drop with paste support — those are genuinely fiddly and worth not writing twice.
Should I resize in the browser before uploading? It is a real optimisation for phone photos — a canvas downscale can cut 8 MB to 400 KB before anything leaves the device, which removes the body-limit problem and the upload wait. Do it in addition to server-side processing, never instead: a client that chooses the output size is a client you are trusting.
Where should the file live, the database or object storage? Object storage, with a row in the database holding the key, the dimensions and the owner. Images in a database column make backups large and slow and give you nothing a bucket does not.
How do I show upload progress?
fetch cannot report request progress. Use XMLHttpRequest and its upload.onprogress event, which is the one remaining reason to reach for it — or skip progress for small files and show a spinner, as the component above does.
Templates in this post
ASoc Sentinel (a security and privacy app landing page with a live protection-status hero), ASoc Signal (an AI platform page showcasing a six-tool image suite) and ASoc Sterling (a wealth-management page built around a dashboard preview) all ship the image-heavy layouts this post is about, already sized and lazy-loaded correctly.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates.
