Skip to main content
ASoc
Tutorial

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.

The ASoc Team11 min read

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:

PathBytes pass throughPractical ceilingGood for
Server ActionYour function~1 MB default action body limitSmall files attached to a form
Route HandlerYour functionPlatform request limit — 4.5 MB on Vercel serverlessCustom headers, streaming
Presigned direct-to-storageNothing — browser to storageThe storage provider's limitAnything 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:

SetTotalAverage per cover
Source JPEG15,102 KiB137 KiB
-card.webp (1060w)3,570 KiB32 KiB
-view.webp (1600w)4,637 KiB42 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

SymptomCauseFix
Cannot set property value of #<HTMLInputElement>Tried to control a file input with valueUse an uncontrolled input; reset by remounting with a key
Preview is blank after an awaitRead event.target.files after the handler yieldedCopy the File into state synchronously, first line
Memory climbs as the user picks filesObject URLs never revokedRevoke in an effect cleanup, and on removal from a list
Previews jump when one image is removedArray index used as the React keyGenerate a stable id per item
413 on upload in production, fine locallyServerless request body limitPresigned direct-to-storage upload
Upload works, the file is corruptContent-Type wrong, or the body re-encodedSend the File as-is in FormData, or PUT the raw blob with its type
Upload succeeds but the image 404sThe bucket is private and the URL is not signedIssue a signed URL after an authorization check; do not make the bucket public
EXIF-rotated photos appear sidewaysBrowsers honour EXIF orientation, your resizer may notNormalise orientation server-side (sharp's rotate() with no argument)
Page is slow despite small filesThe grid's first image is lazy-loadedMark 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.

Keep reading

Tutorial8 min read

React use client: Only 26 of 92 Components Need It Here

This storefront ships 92 components; only 26 carry "use client". What actually earns the directive, and the leaf-component pattern that keeps the rest server-only.

Read more