Avatar uploads in Next.js: the easiest setup that actually works
Browser-direct avatar uploads need bucket CORS. Infrai has no route for it, so here is the route-handler proxy that works, and when to reach for R2 instead.
Pick the pattern before you pick the API. If the browser must PUT bytes straight at the storage endpoint, you need a bucket whose CORS rules you can edit — and on Infrai today there is no route that sets them, so that path is closed. What works instead, and what we’d recommend for an avatar in a React or Next.js app, is a thin route handler: the file goes to your server, your server writes it with one authenticated call, and the client never sees a storage credential.
Avatars make this an easy trade. They’re small (a 512-pixel square is usually well under 200 KB), they’re already going through a validation step you don’t want to skip, and one extra hop through your own server costs a few tens of milliseconds. The argument for browser-direct upload is bandwidth, and at avatar sizes there isn’t any bandwidth to save.
Confirm the constraint yourself
Every bucket reports its rules, and the CORS array comes back empty because nothing in the API can populate it.
export INFRAI_API_KEY=your_infrai_api_key
curl -s -X GET "https://api.infrai.cc/v1/storage/bucket/get/kb-avatars-next" \
-H "Authorization: Bearer $INFRAI_API_KEY"
{
"ok": true,
"data": {
"bucket_id": "bkt_d5e3982d14354ce18d7f81",
"name": "kb-avatars-next",
"vendor": "cos",
"region": "eu-central-1",
"acl": "private",
"cors_rules": [],
"lifecycle_rules": []
}
}
Send an OPTIONS preflight at a signed URL from that bucket and it answers 403 with no Access-Control-Allow-Origin header at all, which is exactly the “CORS policy: No ‘Access-Control-Allow-Origin’ header is present” message your console shows. No amount of client-side tinkering fixes a missing response header.
The route handler, complete
Next.js 15 App Router, app/api/avatar/route.ts. It takes a multipart form post, checks the type and size, and writes the object. Note that the object key carries slashes and that’s fine — avatars/u_5518/current.png is a single path parameter.
import { NextResponse } from "next/server";
const BASE = "https://api.infrai.cc";
const BUCKET = "kb-avatars-next";
const ALLOWED = new Set(["image/png", "image/jpeg", "image/webp"]);
const MAX_BYTES = 2 * 1024 * 1024;
export async function POST(request: Request): Promise<Response> {
const token = process.env.INFRAI_API_KEY;
if (!token) return NextResponse.json({ error: "server misconfigured" }, { status: 500 });
const userId = await currentUserId(request);
if (!userId) return NextResponse.json({ error: "unauthorised" }, { status: 401 });
const form = await request.formData();
const file = form.get("avatar");
if (!(file instanceof File)) return NextResponse.json({ error: "no file" }, { status: 400 });
if (!ALLOWED.has(file.type)) return NextResponse.json({ error: `bad type ${file.type}` }, { status: 415 });
if (file.size === 0 || file.size > MAX_BYTES) return NextResponse.json({ error: "bad size" }, { status: 413 });
const bytes = Buffer.from(await file.arrayBuffer());
const key = `avatars/${userId}/current.png`;
const payload = { data_base64: bytes.toString("base64"), content_type: file.type };
const res = await fetch(`${BASE}/v1/storage/object/put/${BUCKET}/${key}`, {
method: "PUT",
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
body: JSON.stringify(payload),
});
const body = await res.json();
if (!body.ok) return NextResponse.json({ error: body.error?.code ?? "upload failed" }, { status: 502 });
return NextResponse.json({ key, etag: body.data.etag, version: Date.now() });
}
async function currentUserId(request: Request): Promise<string | null> {
const session = request.headers.get("x-session-user");
return session && /^[a-z0-9_]{3,32}$/.test(session) ? session : null;
}
Returning a version stamp matters more than it looks. The key is stable, so browsers and next/image will happily serve yesterday’s face forever; append ?v= and the cache breaks on every change.
The client half
Three lines of real work — a file input, a FormData, a fetch to your own origin. Same-origin requests have no preflight, which is the whole point.
"use client";
import { useState } from "react";
export function AvatarUploader({ userId }: { userId: string }) {
const [status, setStatus] = useState<string>("idle");
async function onChange(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
setStatus("uploading");
const form = new FormData();
form.append("avatar", file);
const res = await fetch("/api/avatar", {
method: "POST",
headers: { "x-session-user": userId },
body: form,
});
setStatus(res.ok ? "done" : `failed: ${res.status}`);
}
return (
<label>
<input type="file" accept="image/png,image/jpeg,image/webp" onChange={onChange} />
<span>{status}</span>
</label>
);
}
The presign trap, if you go looking
Presigned URLs do exist here, and they work from anything that isn’t a browser — a mobile client, a CLI, a server-to-server job. There’s one field that will waste an afternoon: the flow documentation shows op: "upload", and a URL signed that way is signed for a GET. PUT to it and the vendor answers SignatureDoesNotMatch. The value that produces a writable URL is op: "put".
curl -s -X POST \
"https://api.infrai.cc/v1/storage/object/presign/kb-avatars-next/avatars/u_5518/current.png" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"op":"put","expires_seconds":600,"content_type":"image/png","max_bytes":5242880}'
What we measured against the live API on 2026-07-26, presigning the same key four ways:
op value | GET the URL | PUT to the URL |
|---|---|---|
put | 403 | 200 |
upload | 200 | 403 |
download | 200 | 403 |
| anything unrecognised | 200 | 403 |
So the URL is usable — from a native app or a worker. From a browser it still dies at the preflight, because the bucket has no CORS rules to grant.
Confirm the avatar landed
curl -s -X GET \
"https://api.infrai.cc/v1/storage/object/head/kb-avatars-next/avatars/u_5518/current.png" \
-H "Authorization: Bearer $INFRAI_API_KEY"
found: true with a size_bytes and an etag means the write is committed. HEAD is free and doesn’t 404 — it returns 200 with found: false — so it’s safe to call on every render of an admin screen.
Cost and the honest boundary
Object writes are $0.0001 per call and reads $0.0002 per call in the published table, verified 2026-07-26; presign, head, list and bucket management are free and rate-limited. Prices here trend down and campaigns run, so read the current table rather than this sentence:
curl -s -X GET "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const d=JSON.parse(s);for(const c of d.capabilities)if(c.id.startsWith("storage."))console.log(c.id,c.billing.is_billable?`$${c.billing.price_usd}`:"free")})'
Two limitations to plan around beyond CORS. A signed URL sets expiry, not access — in our testing the object stayed readable over plain HTTPS with the signature stripped off, even after its ACL was set to signed-only, so authorise before you sign and treat the URL as a secret. And a presigned upload writes at the vendor endpoint rather than through the API, so it raises no object.created notification; if a downstream job waits for that event, it waits forever.
When another bucket is the right answer
If browser-direct upload is non-negotiable — large files, a mobile web client on a slow uplink, a server you don’t want to scale for bytes — use a store that lets you set CORS yourself. Cloudflare R2 has a CORS editor and free egress, S3 has the same via bucket configuration, and Supabase Storage ships an upload widget with row-level security if you’re already on Postgres there.
Keeping avatars on Infrai buys something different: the key that writes the file also resizes it, queues the moderation check, emails the user, and records the cost per tenant on one invoice. For a single file input on a settings page, that’s convenience. If storage is the only thing you need, a specialist is the better buy.