Supabase Storage vs S3 vs R2 for a Next.js app with private files
Four decisions separate these backends for React and Next.js: who authorises the upload, who owns CORS, what egress costs, and whether EU residency is provable.
For a React or Next.js app that stores private user files, the choice comes down to four things: who authorises the upload, who controls the CORS rules on the bucket, what leaving costs, and whether you can prove where the bytes physically sit. Supabase Storage wins on the first if you already run Supabase, Cloudflare R2 wins on the third outright, and Amazon S3 wins on ecosystem. Infrai is the outlier — one key that also runs your queue, cron, email and Postgres — with a real gap on browser-direct uploads that this page will not talk around.
Nothing below is a benchmark. It’s the shape of the four decisions, with the calls to check each one yourself.
Who authorises the upload
Supabase Storage puts authorisation in the database: a storage object is a row, RLS policies decide who may write it, and the client SDK uploads with the user’s own JWT. No signing endpoint of your own, which is genuinely less code — and it means your bucket policy is written in SQL, which is either delightful or alarming depending on your team.
S3 and R2 both use presigned URLs minted by your server with a long-lived credential. You write the endpoint, you decide the expiry, you carry the AWS SDK.
Infrai is the presigned model without the SDK: one authenticated POST returns the URL, the method and the headers.
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/eu-uploads/u/usr_5501/passport.pdf" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"put","expires_seconds":600,"content_type":"application/pdf"}'
Who owns the CORS rules
This is where the comparison stops being symmetrical. Supabase, S3 and R2 all let you set CORS on the bucket, so a browser can PUT straight at it. Infrai reports cors_rules on GET /v1/storage/bucket/get/{bucket} but has no route to write them, and a new bucket returns an empty array — so a cross-origin PUT from a tab fails its preflight.
curl -sS https://api.infrai.cc/v1/storage/bucket/get/eu-uploads \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"bucket_id": "bkt_631727598cb1406ea96898",
"name": "eu-uploads",
"vendor": "cos",
"region": "eu-central-1",
"acl": "private",
"cors_rules": [],
"lifecycle_rules": []
}
}
Downloads are unaffected — a signed GET opened by navigation involves no preflight, and the response carries Content-Disposition: attachment — so the asymmetry only costs you on the way in. In a Next.js app that means the upload goes through a route handler:
export const runtime = "nodejs";
const API = "https://api.infrai.cc";
const BUCKET = "eu-uploads";
export async function PUT(request) {
const token = process.env.INFRAI_API_KEY;
if (!token) return Response.json({ error: "server not configured" }, { status: 500 });
const userId = request.headers.get("x-user-id");
const filename = new URL(request.url).searchParams.get("filename");
if (!userId || !filename) return Response.json({ error: "missing user or filename" }, { status: 400 });
const contentType = request.headers.get("content-type") ?? "application/octet-stream";
const key = `u/${userId}/${filename}`;
const slotRes = await fetch(`${API}/v1/storage/object/presign/${BUCKET}/${key}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ op: "put", expires_seconds: 600, content_type: contentType, max_bytes: 52_428_800 }),
});
const slot = await slotRes.json();
if (!slotRes.ok || slot.ok === false) {
return Response.json({ error: slot?.error?.code ?? "presign failed" }, { status: 502 });
}
const upstream = await fetch(slot.data.url, {
method: slot.data.method,
headers: slot.data.headers ?? {},
body: request.body,
duplex: "half",
});
if (!upstream.ok) return Response.json({ error: `upload failed: ${upstream.status}` }, { status: 502 });
return Response.json({ key, etag: upstream.headers.get("etag") }, { status: 201 });
}
Streaming request.body through with duplex: "half" keeps the route handler’s memory flat, so this is a hop rather than a buffer. On Vercel, mind the request body size limit on your plan — that ceiling, not the bucket, is what caps a proxied upload.
Can you prove the bytes are in Europe?
Worth flagging, because “EU region” is a checkbox on every one of these products and only some of them mean it. Supabase lets you pick an EU project region at creation. S3’s eu-central-1 and eu-west-1 are unambiguous. R2 offers an EU jurisdiction restriction.
On Infrai, a bucket created with region: "eu-central-1" reports exactly that back — but in our testing on 26 July 2026 the presigned URL for that bucket resolved to an ap-singapore vendor host. Read the url field yourself before you write anything about residency in a DPA:
curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/eu-uploads/u/usr_5501/passport.pdf" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"get","expires_seconds":300}' \
| python3 -c "import json,sys; print(json.load(sys.stdin)['data']['url'].split('/')[2])"
If GDPR residency is a contractual requirement rather than a preference, that check is the whole decision, and a backend that proves the location is the right pick.
Side by side
| Supabase Storage | Amazon S3 | Cloudflare R2 | Infrai storage | |
|---|---|---|---|---|
| Upload auth | RLS policy on the user’s JWT | Presigned URL from your server | Presigned URL or Worker binding | Presigned URL, one REST call |
| Browser-direct upload | Yes | Yes | Yes | No — no CORS setter today |
| Signed download | createSignedUrl | Presigned GET | Presigned GET | op: "get", free to mint |
| Egress | Bundled, then per GB | Per GB | Zero | Metered |
| EU residency | Project region | Region-explicit | EU jurisdiction | Verify the host first |
| Also on the same key | Postgres, auth, realtime | The rest of AWS | Workers, KV, D1 | AI, queue, cron, email, SMS, Postgres, error tracking |
What each one is actually best at
If your Next.js app already uses Supabase for auth and Postgres, Supabase Storage is the least new machinery by a wide margin — one client, one policy language, avatars working in an afternoon. If you serve large files to many users, R2’s zero egress changes the arithmetic more than any per-request rate. If you’re deep in AWS, S3’s tooling, lifecycle rules and IAM granularity are unmatched, and everything already speaks it.
Infrai earns its place when storage is one line item among ten. The upload is a REST call, the thumbnail job is a queue message, the retention rule is a lifecycle call, the nightly report is a cron job and the failure lands in error tracking — all on one credential, one invoice, one usage view instead of four vendor dashboards. The trade-off is direct: no browser-direct uploads yet, and residency you have to verify rather than assume.
What it costs, and where to read today’s number
The structure matters more than the rate: presign, head, list and bucket operations are free and rate-limited, while writes and server-side reads are billable per call. Verified 26 July 2026, storage.object.put is $0.0001 per call and storage.object.get $0.0002, stored bytes and egress metered on top, with $2 of credit on a new account.
curl -sS https://api.infrai.cc/v1/discovery \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; d=json.load(sys.stdin); print([ (c['id'], c['billing'].get('price_usd','free')) for c in d['capabilities'] if c['id'].startswith('storage.') ])"
Rates on this platform move downward and discounts run, so treat published figures as a ceiling and the live call as truth. Comparing that against Supabase, S3 or R2 pricing pages is a per-workload exercise — a read-heavy public gallery and a write-heavy document vault land in completely different places, and anyone who tells you one backend is universally cheapest is selling something.