Browser upload, backend thumbnails: what actually works in Next.js
Direct-to-bucket uploads don't work on Infrai yet. Here's the route-handler pattern that replaces them, plus the calls to presign, store and verify each rendition.
The pattern most tutorials teach is: browser asks your API for a presigned PUT, browser uploads straight to the bucket, a bucket event wakes a worker that writes the thumbnails. It’s a good design and you should use it — on Infrai, one leg of it doesn’t run today. There’s no route to set bucket CORS rules, and cors_rules handed to bucket/create is quietly dropped, so a browser fetch to an Infrai presigned URL dies in preflight.
So on Infrai the bytes go through your Next.js route handler. That’s a real cost in bandwidth and function time, and it’s worth knowing before you build.
Three topologies, and who they’re for
| Topology | Who carries the bytes | Needs bucket CORS | Works on Infrai today | Best when |
|---|---|---|---|---|
| Browser → presigned PUT → bucket | nobody but the client | yes | no | large files, high upload volume |
| Browser → your route handler → bucket | your server, twice | no | yes | images under ~10 MB, validation matters |
| Browser → your API → server fetches a remote URL | your server, once | no | yes | imports from another service |
If the first row is a hard requirement — phone-camera originals at 12 MB a shot, thousands a day — Cloudflare R2 or S3 is the honest recommendation, because both let you set CORS on the bucket and both are S3-compatible so the code you write ports back later. Backblaze B2 does the same thing at a lower storage rate if egress is modest.
For the middle row, which covers most avatar and product-photo flows, the proxy hop is cheap and buys you something: you get to reject a 40 MB TIFF before it ever reaches storage.
The route handler
App Router, Node runtime (not edge — sharp needs it). This validates first, then presigns, then streams. The Infrai key never leaves the server.
import { NextRequest, NextResponse } from "next/server";
import { createHash, randomUUID } from "node:crypto";
export const runtime = "nodejs";
const API = "https://api.infrai.cc";
const BUCKET = "kb-uploads-0726";
const MAX_BYTES = 10 * 1024 * 1024;
const ALLOWED = new Set(["image/png", "image/jpeg", "image/webp"]);
async function presign(key: string, op: "get" | "put", ttl: number) {
const res = await fetch(`${API}/v1/storage/object/presign/${BUCKET}/${key}`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.INFRAI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ op, expires_seconds: ttl }),
});
const json = await res.json();
if (!res.ok || !json.ok) throw new Error(`presign failed: HTTP ${res.status}`);
return json.data as { url: string; method?: string; headers?: Record<string, string> };
}
export async function POST(req: NextRequest) {
const userId = req.headers.get("x-user-id");
if (!userId) return NextResponse.json({ error: "unauthenticated" }, { status: 401 });
const form = await req.formData();
const file = form.get("file");
if (!(file instanceof File)) return NextResponse.json({ error: "no file" }, { status: 400 });
if (!ALLOWED.has(file.type)) return NextResponse.json({ error: "bad type" }, { status: 415 });
if (file.size > MAX_BYTES) return NextResponse.json({ error: "too large" }, { status: 413 });
const bytes = Buffer.from(await file.arrayBuffer());
const digest = createHash("sha256").update(bytes).digest("hex").slice(0, 12);
const key = `incoming/u_${userId}/${digest}.png`;
const slot = await presign(key, "put", 300);
const put = await fetch(slot.url, {
method: slot.method ?? "PUT",
headers: { ...(slot.headers ?? {}), "Content-Type": file.type },
body: bytes,
});
if (!put.ok) return NextResponse.json({ error: `upload failed ${put.status}` }, { status: 502 });
return NextResponse.json({ job: randomUUID(), key, digest }, { status: 202 });
}
Note the 202. The handler’s job ends the moment the original is safe; thumbnails are somebody else’s problem, and making the user wait for two sharp encodes is how a 300ms upload becomes a 2s one.
The presign call, on its own
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/storage/object/presign/kb-uploads-0726/incoming/u_4821/9c1de0f4a2b7.png" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"put","expires_seconds":300}'
{
"ok": true,
"data": {
"url": "https://infrai-1333115350.cos.ap-singapore.myqcloud.com/a4ee0c441fa36c267.kb-uploads-0726/incoming/u_4821/9c1de0f4a2b7.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=300&X-Amz-Signature=c017e8372be6bd",
"method": "PUT",
"headers": null,
"fields": null,
"expires_at": "2026-07-26T00:47:23.254599Z",
"max_bytes": null
}
}
Presigning is free and rate-limited, so there’s no reason to cache the slot. Mint one, use it, throw it away.
The worker that makes the renditions
Because the upload went through your code, you already know when it finished — enqueue the resize job right there instead of waiting for a storage event. That’s the part of the standard pattern the CORS gap actually improves: no webhook round trip, no eventual-consistency window, no duplicate deliveries to deduplicate.
import sharp from "sharp";
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const BUCKET = "kb-uploads-0726";
const SIZES = [64, 256];
async function slotFor(objectKey, op) {
const res = await fetch(`${API}/v1/storage/object/presign/${BUCKET}/${objectKey}`, {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ op, expires_seconds: 300 }),
});
const json = await res.json();
if (!res.ok || !json.ok) throw new Error(`presign ${op} ${objectKey}: HTTP ${res.status}`);
return json.data;
}
export async function makeRenditions(userId, digest) {
const sourceKey = `incoming/u_${userId}/${digest}.png`;
const download = await slotFor(sourceKey, "get");
const res = await fetch(download.url);
if (!res.ok) throw new Error(`source unreadable: HTTP ${res.status}`);
const original = Buffer.from(await res.arrayBuffer());
const made = [];
for (const size of SIZES) {
const out = await sharp(original)
.resize({ width: size, height: size, fit: "cover" })
.webp({ quality: 78 })
.toBuffer();
const key = `derived/u_${userId}/${digest}/${size}.webp`;
const slot = await slotFor(key, "put");
const up = await fetch(slot.url, {
method: slot.method ?? "PUT",
headers: { "Content-Type": "image/webp" },
body: out,
});
if (!up.ok) throw new Error(`rendition ${size} failed: HTTP ${up.status}`);
made.push({ key, bytes: out.length });
}
return made;
}
Verify without downloading anything
GET /v1/storage/object/list/{bucket} takes prefix, delimiter, cursor and limit, costs nothing, and answers “did the fan-out finish” in one call.
curl -sS "https://api.infrai.cc/v1/storage/object/list/kb-uploads-0726?prefix=derived/u_4821/&limit=50" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"items": [
{
"bucket_id": "bkt_e9f087f2f0f14b6ba50333",
"key": "derived/u_4821/9c1de0f4a2b7/256.webp",
"size_bytes": 70,
"etag": "b357a19c87624c7c4d131aeeb4ae677f",
"content_type": null,
"metadata": null,
"last_modified": "2026-07-26T00:37:06Z"
}
],
"next_cursor": null
}
}
Two things to know about that response. content_type comes back null from list even when head reports a real MIME type on the same object, so don’t build a filter on it. And pagination is cursor-based — keep passing the next_cursor you were handed until it arrives null.
When you do want the storage event
If objects can also arrive by paths your API doesn’t see — a batch import, another service writing into the same bucket — subscribe instead of polling.
curl -sS -X POST "https://api.infrai.cc/v1/storage/bucket/set_notification/kb-uploads-0726" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"events":["object.created"],"target":{"url":"https://example.com/storage-events"}}'
It returns a subscription_id, and the callback receives an X-Infrai-Event header with bucket, key and timestamp in the body. Treat delivery as at-least-once and make the handler idempotent on key — and test it against your staging endpoint before you depend on it, rather than assuming.
What this costs, roughly
Presign, head, list and the bucket calls are free and rate-limited. Only the two byte-moving routes are metered per call: PUT /v1/storage/object/put/{bucket}/{key} at $0.0001 and GET /v1/storage/object/get/{bucket}/{key} at $0.0002, both verified 26 July 2026. Because this design uploads through presigned URLs rather than the JSON put route, an avatar upload with two renditions bills as three presigns, which is to say nothing at all. Check your own account rather than the article:
curl -sS "https://api.infrai.cc/v1/account/usage" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Rates move down over time and discount campaigns run, so treat the figures above as a ceiling. New accounts get $2 of free credit to burn through first.
Limits and the honest comparison
The CORS gap is the big one, and it’s a limitation rather than a preference: until there’s a route to set CORS rules, browser-direct upload to an Infrai bucket cannot work, and your route handler pays the bandwidth. Next.js also caps request body size on some hosts, so a 10 MB image needs the platform limit raised or a streaming parser.
There’s no image processing in the storage API either — sharp does the work here, exactly as it would against S3 — so if you wanted the resize to disappear entirely, Cloudinary transforms on request and is a fair buy for an image-only product.
What Infrai gives back is the second question. The queue that holds the resize job, the cron that sweeps orphaned incoming/ keys, the error tracker that catches the sharp exception and the usage view that attributes it all to a tenant are on the same key and the same bill. If storage is the only thing you need, a specialist is probably cheaper.