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. Three of the four candidates are storage products. Infrai is the outlier — one key that also runs your queue, cron, email and error tracking — and it has 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 RLS, presigned S3 and R2 URLs, or one REST call
Supabase Storage puts authorisation in the database: an object is a row, RLS policies decide who may write it, and the client SDK uploads with the user’s own JWT. There’s no signing endpoint of your own to maintain, which is genuinely less code — and it means your bucket policy is written in SQL, which is either delightful or alarming depending on the team.
The other two hand your server a long-lived credential and have it mint presigned URLs. You write the endpoint, you decide the expiry, you carry the SDK.
Infrai is that same 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/app-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"}'
op accepts get and put and nothing else — anything else is a 400 that says so, rather than a URL that turns out to be the wrong kind of slot. max_bytes is enforced at the storage host, so a client that ignores your size limit gets rejected by the signature rather than by your goodwill.
Who owns the CORS rules, and where browser-direct upload actually works
This is where the comparison stops being symmetrical. The other three let you write CORS onto the bucket and have a browser PUT straight at it, which is the whole appeal of a direct upload.
On Infrai the control plane takes the rules. POST /v1/storage/bucket/set_cors/{bucket} stores them and GET /v1/storage/bucket/get/{bucket} reads them back:
curl -sS -X POST https://api.infrai.cc/v1/storage/bucket/set_cors/app-uploads \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"rules":[{"allowed_origins":["https://app.example.com"],"allowed_methods":["PUT","GET"],"allowed_headers":["*"],"max_age_seconds":3600}]}'
{
"ok": true,
"data": {
"bucket": "app-uploads",
"cors_rules": [
{
"allowed_origins": ["https://app.example.com"],
"allowed_methods": ["PUT", "GET"],
"allowed_headers": ["*"],
"max_age_seconds": 3600
}
]
}
}
What the storage host does with a real browser preflight is the part that matters, and today it does not answer one with those headers — so a cross-origin PUT from a tab still fails and the upload belongs in a route handler. If page-to-bucket is a hard requirement for you, that is a reason to pick one of the other three, and we’d rather say so than let you find out in a browser console.
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 = "app-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. The other three each give you a region or jurisdiction you can point at in a DPA.
Infrai’s object storage is physically provisioned in one region today, and it tells you so at creation time rather than at audit time:
curl -sS -X POST https://api.infrai.cc/v1/storage/bucket/create \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"name":"eu-customer-docs","region":"eu-central-1","acl":"private"}'
{
"ok": false,
"error": {
"code": "INVALID_ARGUMENT",
"http_status": 400,
"message": "COS is physically provisioned in ap-singapore; requested region eu-central-1 is unavailable",
"retryable": false
}
}
That is a straight answer, and it is the whole decision if data residency is contractual rather than a preference. If your DPA promises Frankfurt, one of the other three is the right backend and no amount of platform breadth changes that.
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 — rules store, preflight doesn’t pass yet |
| Signed download | createSignedUrl | Presigned GET | Presigned GET | op: "get", free to mint |
| Egress | Bundled, then per GB | Per GB | Zero | Per GB, metered |
| EU residency | Project region | Region-explicit | EU jurisdiction | Not offered — 400 at create |
| Also on the same key | Postgres, auth, realtime | The rest of AWS | Workers, KV, D1 | AI, queue, cron, email, SMS, error tracking |
What each one is actually best at
If your Next.js app already uses Supabase for auth and Postgres, its 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 can. 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 POST /v1/queue/publish, the retention rule is a POST /v1/storage/bucket/set_lifecycle/{bucket}, the nightly report is a POST /v1/cron/create and the failure lands in POST /v1/errors/capture — every one of those already on the same account as the bucket, with no second vendor to onboard, no second key to rotate and no second invoice to reconcile. The trade-offs are the two above: uploads relay through your server, and residency is Singapore.
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. Writes are billed per call — storage.object.put at $0.0001, verified 2026-07-27.
Reads are not per call: storage.object.get meters egress at $0.104 per GB, with stored bytes billed separately again. That is the number to model, because it makes rendition size the lever — a 40 KB avatar thumbnail and the 4 MB original it came from differ by about two orders of magnitude on the same traffic. New accounts start with $2 of credit.
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 any published figure as a ceiling and the live call as truth. Comparing it against the others’ 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.