React document uploads: presigned direct, proxy, or JSON body?

Three upload patterns for user documents, what S3-compatible really means for your SDK choice, and which one a React and Node 22 app can ship on Infrai today.

Three patterns compete for this job: the browser uploads straight to the bucket with a presigned URL, the browser posts to your API and your API forwards the bytes, or the browser sends the file as base64 inside a JSON request. Pattern one is the one every S3 tutorial teaches and the one you can’t use on Infrai today, because a bucket’s CORS rules aren’t writable and the preflight fails. Patterns two and three both work, and the file size decides between them.

Infrai’s storage API is REST, one key, and the same credential that stores the contract also runs the virus-scan queue and the retention cron. What follows is the comparison, then a React uploader and a Node 22 handler you can paste.

The three patterns side by side

PatternBytes touch your serverCORS rule requiredPractical ceilingAvailable on Infrai
Browser → presigned PUT → bucketNoYes, on the bucket5 GB single PUTNo — preflight is refused
Browser → your API → bucketYes, streamedNoWhatever your host toleratesYes
Browser → your API as base64 JSONYes, bufferedNo1 MB, per the docsYes
Browser → S3 / R2 / MinIO directlyNoYes, and you own it5 GB single PUTNot applicable

The last row is the honest escape hatch. Amazon S3, Cloudflare R2 and a self-hosted MinIO all let you write a CORS policy, so if a zero-touch upload path is a hard architectural requirement, use one of them for that bucket. The mechanics of why the preflight fails — and how to reproduce it in one curl command — are covered at docs.infrai.cc/en/guides/storage/answers/browser-direct-avatar-upload-object-storage-cors-presig/, so this page won’t repeat it.

What “S3-compatible” buys you, and what it doesn’t

The buckets underneath are S3-compatible — keys with slashes behave like folders, ETags are content hashes, multipart follows the familiar part-number-plus-ETag dance, and the vendor field on a bucket reads r2, s3, oss or cos. The presigned URLs are AWS SigV4, which is why they work with any HTTP client.

What you don’t get is an S3 API endpoint to point @aws-sdk/client-s3 at. The control surface is plain REST: PUT /v1/storage/object/put/{bucket}/{key}, POST /v1/storage/object/presign/{bucket}/{key}, and so on. If your codebase is already built on the AWS SDK and you were hoping to change one endpoint string, that’s not the migration available here — it’s a rewrite of the call sites, roughly an afternoon for a typical app, and there’s no SDK to install afterwards.

The React side

fetch still can’t report upload progress, so a document uploader that shows a progress bar uses XMLHttpRequest. No JSX here — this is the piece you’d import into a component:

export function uploadDocument(file, { onProgress, signal } = {}) {
  return new Promise((resolve, reject) => {
    const form = new FormData();
    form.append("file", file, file.name);

    const xhr = new XMLHttpRequest();
    xhr.open("POST", "/api/documents");
    xhr.responseType = "json";

    xhr.upload.addEventListener("progress", (e) => {
      if (e.lengthComputable && onProgress) onProgress(Math.round((e.loaded / e.total) * 100));
    });
    xhr.addEventListener("load", () => {
      if (xhr.status >= 200 && xhr.status < 300) resolve(xhr.response);
      else reject(new Error(xhr.response?.error ?? `upload failed: ${xhr.status}`));
    });
    xhr.addEventListener("error", () => reject(new Error("network error")));
    xhr.addEventListener("abort", () => reject(new Error("cancelled")));
    if (signal) signal.addEventListener("abort", () => xhr.abort());

    xhr.send(form);
  });
}

Wire onProgress to a state setter and signal to an AbortController you cancel when the component unmounts. Users abandon uploads constantly — a page navigation that leaves a 40 MB transfer running is a bill you didn’t need to pay.

The Node side, with the validation nobody writes

Content type from the browser is a suggestion, not evidence. Sniff the magic bytes, cap the size, and put the tenant in the key path so one customer’s documents can’t be enumerated by guessing another’s:

const API = "https://api.infrai.cc";
const BUCKET = "documents-demo";
const token = process.env.INFRAI_API_KEY;
if (!token) throw new Error("INFRAI_API_KEY is not set");
const MAX_JSON_BYTES = 1_048_576;

const SIGNATURES = [
  { type: "application/pdf", magic: [0x25, 0x50, 0x44, 0x46] },
  { type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", magic: [0x50, 0x4b, 0x03, 0x04] },
];

function detect(buffer) {
  return SIGNATURES.find((s) => s.magic.every((b, i) => buffer[i] === b))?.type ?? null;
}

export async function POST(request) {
  const form = await request.formData();
  const file = form.get("file");
  if (!file) return Response.json({ error: "no file" }, { status: 400 });

  const bytes = Buffer.from(await file.arrayBuffer());
  const type = detect(bytes);
  if (!type) return Response.json({ error: "only pdf and docx are accepted" }, { status: 415 });
  if (bytes.byteLength > MAX_JSON_BYTES) {
    return Response.json({ error: "use the multipart path above 1 MB" }, { status: 413 });
  }

  const key = `tenants/t_318/usr_8412/${file.name}`;
  const payload = { data_base64: bytes.toString("base64"), content_type: type };
  const res = await fetch(`${API}/v1/storage/object/put/${BUCKET}/${key}`, {
    method: "PUT",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
    body: JSON.stringify(payload),
  });
  const stored = await res.json();
  if (!res.ok || stored.ok === false) {
    return Response.json({ error: stored?.error?.code ?? `HTTP ${res.status}` }, { status: 502 });
  }
  return Response.json({ key: stored.data.key, etag: stored.data.etag, bytes: stored.data.size_bytes }, { status: 201 });
}

That’s a Next.js App Router route handler; the same body works under any framework that gives you a Request. Above the 1 MB line, don’t send base64 — sign an upload URL from your server and stream the bytes to it, or open a multipart upload and push 5 MiB parts. The base64 path buffers the whole file twice (once as bytes, once as a base64 string that’s about a third larger), and that’s the number that decides where the ceiling sits.

Verify the object landed, with a concrete key:

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS \
  "https://api.infrai.cc/v1/storage/object/head/documents-demo/tenants/t_318/usr_8412/contract.pdf" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "found": true,
    "status": "found",
    "key": "tenants/t_318/usr_8412/contract.pdf",
    "size_bytes": 20,
    "etag": "b18a1058ce5f3b4bf5d0b6a50ec7e600",
    "content_type": "application/pdf",
    "metadata": null,
    "last_modified": "2026-07-26T00:50:55Z"
  }
}

And list one tenant’s documents — the prefix is doing the access-control work your key scheme set up:

curl -sS \
  "https://api.infrai.cc/v1/storage/object/list/documents-demo?prefix=tenants%2Ft_318%2F" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

Giving the document back

Downloads are the easy half: sign a short-lived GET and let the browser navigate to it. No CORS is involved, because a navigation isn’t a cross-origin fetch.

curl -sS -X POST \
  "https://api.infrai.cc/v1/storage/object/presign/documents-demo/tenants/t_318/usr_8412/contract.pdf" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"op":"get","expires_seconds":300}'

Numbers that constrain the design

Single-part uploads are capped at 5 GB by the S3 semantics underneath; multipart parts must be at least 5 MiB except the last, with at most 10,000 of them. Infrai’s base64 route is documented as “not recommended above 1 MB”. Storing an object costs $0.0001 per call and reading one server-side costs $0.0002, verified 26 July 2026, with presign, head and list free and rate-limited, and stored bytes plus egress billed separately. Ten thousand documents is a dollar in write fees — the storage itself will be the bigger line. Read today’s rates straight from the API:

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.') ])"

Rates on this platform have moved down over time and promotional pricing runs, so what you read may beat what’s printed here.

The trade-off, stated plainly

Proxying uploads costs you bandwidth and a little latency on your own host, and that’s the price of not being able to configure bucket CORS. For documents — files measured in megabytes, uploaded a handful of times per user per month — it’s a price worth paying, and you get server-side validation for free because the bytes pass through you anyway. For a media product pushing terabytes of user video, it isn’t, and a bucket you control the CORS on is the better tool. Oversized payloads come back as STORAGE_OBJECT_TOO_LARGE, which is the signal to move that path to multipart rather than to raise a limit.

References

Browse more storage developer guides