React file uploads to object storage: the five steps that matter
Picking a file, validating it, showing real progress, cancelling cleanly — plus which upload architecture actually works against Infrai storage from a browser.
Five steps: get a File out of the input, validate it on the client and again on the server, obtain a destination from your backend, send the bytes with progress you can actually render, and confirm the object exists before you write a row to your database. On Infrai the third step returns a presigned URL from POST /v1/storage/object/presign/{bucket}/{key}, and the fourth is where a browser hits a wall that a Node script never does.
The wall is CORS, and it’s worth naming immediately so you design around it rather than debugging it at 2am. Infrai has no route to set bucket CORS rules — a cors_rules array passed to bucket create is accepted and silently dropped — so a preflight from https://yourapp.example.com to the storage origin comes back 403 and the browser cancels the request before a single byte moves. For true browser-to-storage uploads today you’d be better off on Cloudflare R2 or S3, where CORS is yours to configure. Against Infrai, the working React pattern routes bytes through your own API.
Step 1: the input, and the File you get back
Uncontrolled input, ref, done. React state holds the File object, not the input value — browsers won’t let you set that programmatically anyway.
import { useRef, useState } from "react";
type Picked = { file: File; previewUrl: string | null };
const MAX_BYTES = 10 * 1024 * 1024;
const ALLOWED = ["image/png", "image/jpeg", "image/webp", "application/pdf"];
export function FilePicker({ onPick }: { onPick: (p: Picked) => void }) {
const inputRef = useRef<HTMLInputElement>(null);
const [error, setError] = useState<string | null>(null);
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
if (!ALLOWED.includes(file.type)) return setError(`${file.type || "unknown type"} is not accepted`);
if (file.size > MAX_BYTES) return setError(`${(file.size / 1048576).toFixed(1)} MB exceeds the 10 MB limit`);
setError(null);
onPick({ file, previewUrl: file.type.startsWith("image/") ? URL.createObjectURL(file) : null });
}
return (
<div>
<input ref={inputRef} type="file" accept={ALLOWED.join(",")} onChange={handleChange} />
{error && <p role="alert">{error}</p>}
</div>
);
}
Client-side validation is a courtesy to the user, never a control. Anyone can POST to your endpoint with curl, so the same two checks belong on the server — and max_bytes on the presign call gives you a third enforcement point at the storage layer itself.
Step 2: ask your backend where to put it
Never let the client choose the key. The browser sends a filename and a content type; your server derives the key from the session, which is what stops user 91 from writing over user 12’s avatar.
// routes/uploads.mjs — Node 22 ESM, express@4
import express from "express";
import { randomUUID } from "node:crypto";
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
const BUCKET = "app-uploads";
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
export const router = express.Router();
router.post("/uploads/intent", express.json(), async (req, res) => {
const user = req.session?.user;
if (!user) return res.status(401).json({ error: "sign in first" });
const { contentType, sizeBytes } = req.body ?? {};
if (!["image/png", "image/jpeg", "image/webp", "application/pdf"].includes(contentType)) {
return res.status(415).json({ error: "unsupported content type" });
}
if (!Number.isInteger(sizeBytes) || sizeBytes > 10 * 1024 * 1024) {
return res.status(413).json({ error: "too large" });
}
const objectKey = `u/${user.id}/${randomUUID()}`;
const payload = { op: "put", expires_seconds: 300, content_type: contentType, max_bytes: sizeBytes };
const upstream = await fetch(`${API}/v1/storage/object/presign/${BUCKET}/${objectKey}`, {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!upstream.ok) return res.status(502).json({ error: `presign failed: ${upstream.status}` });
const { data } = await upstream.json();
res.json({ objectKey, url: data.url, method: data.method ?? "PUT", headers: data.headers ?? {}, expiresAt: data.expires_at });
});
The presign call itself is free and returns in well under 100 ms in our testing, so there’s no reason to cache these — mint one per upload with a short TTL.
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/app-uploads/u/42/demo.png" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"put","expires_seconds":300,"content_type":"image/png","max_bytes":1048576}'
{
"ok": true,
"data": {
"url": "https://<storage-origin>/<account-prefix>.app-uploads/u/42/demo.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=300&X-Amz-Signature=…",
"method": "PUT",
"headers": { "Content-Type": "image/png" },
"fields": null,
"expires_at": "2026-07-26T00:44:00.425159Z",
"max_bytes": 1048576
}
}
Step 3: prove to yourself where the bytes can go
Before you wire the client to that URL, run the preflight the browser would run. This is the single check that decides your architecture:
curl -sS -X OPTIONS "https://<storage-origin>/<account-prefix>.app-uploads/u/42/demo.png" \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: PUT" \
-H "Access-Control-Request-Headers: content-type" \
-D - -o /dev/null
# HTTP/1.1 403 Forbidden
# (no Access-Control-Allow-Origin header)
No Access-Control-Allow-Origin means no browser upload. A fetch from your React app to that URL will fail with an opaque network error and an empty response — the classic symptom that sends people hunting for a bug in their own code.
Step 4: send the bytes, with progress
Because the browser can’t reach the storage origin, the React client posts to your own API and your API relays the bytes onward. Use XMLHttpRequest, not fetch: only XHR exposes upload progress events, and progress is the difference between a 40 MB upload feeling broken and feeling slow.
// upload.js — plain browser JS, no dependencies
export function uploadWithProgress(file, onProgress, signal) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/uploads/stream", true);
xhr.setRequestHeader("X-Content-Type", file.type);
xhr.upload.addEventListener("progress", (event) => {
if (event.lengthComputable) onProgress(Math.round((event.loaded / event.total) * 100));
});
xhr.addEventListener("load", () => {
if (xhr.status >= 200 && xhr.status < 300) resolve(JSON.parse(xhr.responseText));
else reject(new Error(`upload failed: HTTP ${xhr.status}`));
});
xhr.addEventListener("error", () => reject(new Error("network error during upload")));
xhr.addEventListener("abort", () => reject(new Error("cancelled")));
if (signal) signal.addEventListener("abort", () => xhr.abort(), { once: true });
xhr.send(file);
});
}
Your /api/uploads/stream handler reads the body and forwards it to PUT /v1/storage/object/put/{bucket}/{key} as base64 — that route is documented for small files and isn’t recommended above 1 MB, so for anything larger have the handler forward to the presigned URL from step 2 instead, server to server, where CORS doesn’t apply.
Step 5: confirm before you commit a row
curl -sS "https://api.infrai.cc/v1/storage/object/head/app-uploads/u/42/demo.png" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
found, size_bytes and etag come back without transferring the body, and the call is free — so there’s no excuse for a database row that points at an object nobody checked.
Which architecture, and what it costs
| Architecture | Works from a browser on Infrai | Bytes through your server | Progress bar |
|---|---|---|---|
| Client → your API → Infrai storage | yes | yes | XHR upload events |
| Client → presigned Infrai URL | no, preflight 403 | no | XHR upload events |
| Client → presigned R2 or S3 URL | yes, with CORS configured | no | XHR upload events |
| Client → your API → R2 or S3 | yes | yes | XHR upload events |
On price, verified 26 July 2026: presign, head and list are free (rate-limited); PUT /v1/storage/object/put/{bucket}/{key} is $0.0001 per call and GET /v1/storage/object/get/{bucket}/{key} is $0.0002, so reads cost about twice writes. New accounts get $2 of trial credit, which is roughly twenty thousand uploads before anything is billed. These rates drift downward, so read them live:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; [print(c['id'], c['billing'].get('price_usd','free')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.object')]"
If a browser-direct upload with no proxy hop is a hard requirement — huge files, bandwidth you don’t want to pay twice for — take that one workload to R2 or S3 and keep the rest of your stack where it is. The trade-off runs the other way for most React apps: relaying a 2 MB avatar through an API route you already operate costs you nothing you’d notice, and it keeps uploads, the queue that processes them and the error tracking that catches them on a single key.