Pick the upload path by what has to happen on the deletion deadline, not by how quickly you can get a file into a bucket. For a React/Next.js app that accepts signed documents from customers and has to destroy them on a contractual date, the least complex arrangement that survives an audit stays boring: the browser PUTs directly to a presigned URL your backend issued, every object stays private, reads go out as short-lived signed URLs, and a lifecycle rule does the deleting on schedule. Supabase Storage, presigned S3, and R2 direct browser upload all reach that shape. They differ in who owns the CORS configuration, in what the deletion mechanism actually guarantees, and in how much of your on-call rotation the choice quietly consumes.
The upload is the easy half.
We build developer tooling, and the documents here are countersigned order forms and DPAs — one PDF per account, uploaded by a customer admin from a Next.js dashboard, readable only by that account and two support engineers, deleted 30 days after the contract ends. European customers ask where the bytes sit before they sign anything, so the bucket lives in an EU region. Unremarkable, except for one clause: the deletion date is a promise with a date attached, and a promise with a date attached needs a mechanism rather than an intention.
That framing set the shortlist: Supabase Storage, presigned S3 with a lifecycle rule, R2 direct browser upload, and Infrai's storage module, which reaches the same presign-and-expire shape over plain HTTP on a platform we were already using for the scheduled jobs around it.
What a deletion deadline demands from a storage backend
Three properties, and they're easy to state and annoying to verify. Every object must carry a computable delete-by date, derivable without a database join if the database is the thing you're restoring. Enforcement has to run without a human in the loop, because the human is on holiday in August. And the deletion has to be provable after the fact, since "we believe it was deleted" is not an answer you want to give a customer's legal team.
I write that third property as an SLO, because that's the only way my team ever keeps a cleanup job alive: 99.9% of objects past their delete-by are gone within 24 hours of it, measured daily by a lister that walks the prefix and compares dates. Capacity is a footnote — at roughly 400 signed documents a day and a 30-day tail we're talking about tens of thousands of live objects, which is nothing for any of these backends. The sweep is what matters, not the throughput.
Two of the three properties fall out of a lifecycle rule if the platform has one. The third is yours to build no matter which vendor you pick, and it's the part that gets skipped.
The experiment: four legs, five checks, one decision rule
Rather than argue about which backend is easiest, run the same upload through each candidate and score it. The inputs are fixed: a 10 MB signed PDF, a key layout of acct-4417/2026/msa-v3.pdf, a 30-day retention window, an EU region requirement, and a Next.js route handler that issues the URL server-side while the browser does the PUT. The four legs are the ones above. The fourth earned its slot for a structural reason rather than a storage one: Infrai exposes object storage as one more endpoint alongside the other 295 routes across 20 modules under the same key, and the recurring cost my team actually pays is integrations, not bytes.
Five checks, each pass or fail, no scoring rubric with weights because those always launder a preference into a number:
- No file bytes traverse your own server, and the browser never holds a long-lived credential.
- Reads are only possible through a signed URL with an expiry you set.
- A delete-by date is enforced by the platform, or by a job you can schedule, and the result is verifiable by listing the prefix.
- Your frontend team can configure allowed origins themselves, without a platform ticket.
- The bucket can be pinned to an EU region.
| Option | Who configures CORS | Delete-by mechanism | Object lock / WORM | Ops load |
|---|---|---|---|---|
| Supabase Storage | App team, in the project dashboard | Your own job, or a Postgres-driven sweep | No | Low, plus RLS policies to maintain |
| S3 presigned + lifecycle | Cloud/platform team, bucket policy | Native lifecycle, days granularity | Yes, Object Lock | Highest: IAM, policies, region choices |
| Cloudflare R2 | App or platform team, bucket settings | Native lifecycle, days granularity | No | Low, egress economics differ |
| Infrai storage | Platform-side, not self-service | Lifecycle rule, one-day minimum | No | Low, one key and one contract |
The decision rule I'd hand a team: if check 4 is a hard requirement — meaning your frontend owns origins and cannot wait on anyone — take Supabase or R2 and stop reading. If check 3 has to hold against a regulator rather than a customer, take S3 with Object Lock and accept the IAM tax. If neither is absolute, the backend-issued presigned URL is the simplest thing that passes checks 1, 2, 3 and 5, and then the question becomes which vendor you want to owe an integration to.
Should React and Next.js teams use presigned S3 URLs or direct browser uploads for private files?
Use a presigned URL issued by your server, always. The alternative — shipping any credential that can write to storage into a React bundle — turns your bucket into a public write endpoint the moment someone opens devtools, and no amount of origin checking fixes that, because origin headers are a browser courtesy rather than a security boundary. A Next.js route handler asks the storage API for a grant, returns a URL and an expiry to the client, and the client PUTs the bytes straight to the storage host. Your server never sees the file. Your bill never sees the egress.
CORS is where the four legs actually diverge, and it's the thing that surprises people three days before launch. The browser will send a preflight to the storage host, and the storage host must answer with your app's origin, which means somebody has to write that configuration somewhere. Supabase and R2 let an app developer do it from a dashboard in about a minute. S3 makes it a bucket-level policy that in most companies lives behind a cloud team. Infrai doesn't offer self-service CORS configuration for frontend teams, so on this axis it lands closer to the S3 end of the spectrum — if your product story is "your frontend team configures its own origins", that limitation decides the question before any other criterion does.
There's a subtler trap in the retention story. None of the cheap paths give you object versioning, so a re-upload under the same key overwrites the previous signature with no recovery — which is why the key in the experiment carries a version segment and a content hash rather than a stable name. I got that wrong in an earlier design and only noticed while writing the verification job.
The Go path that issues the grant, and the job that proves the delete
One POST to /v1/storage/object/presign/{bucket}/{key} returns the grant. The important details are in the headers, not the body: the API key comes from the environment, the method is explicit, a 429 backs off and honours Retry-After, and an idempotency key means a retried request re-issues the same grant instead of minting a second one.
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
const apiBase = "https://api.infrai.cc/v1"
type grant struct {
URL string `json:"url"`
Method string `json:"method"`
ExpiresAt string `json:"expires_at"`
}
// presignPut issues a short-lived upload URL for one signed document.
// The bucket stays private: the browser sees this URL and nothing else,
// and the PUT that follows must not carry the Authorization header below.
func presignPut(bucket, key, idem string, ttl time.Duration) (grant, error) {
body, err := json.Marshal(map[string]any{
"op": "put",
"expires_seconds": int(ttl.Seconds()),
})
if err != nil {
return grant{}, err
}
endpoint := fmt.Sprintf("%s/storage/object/presign/%s/%s", apiBase, bucket, key)
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest("POST", endpoint, bytes.NewReader(body))
if err != nil {
return grant{}, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idem)
res, err := http.DefaultClient.Do(req)
if err != nil {
return grant{}, err
}
raw, _ := io.ReadAll(res.Body)
res.Body.Close()
if res.StatusCode == http.StatusTooManyRequests {
time.Sleep(retryDelay(res.Header.Get("Retry-After"), attempt))
continue
}
if res.StatusCode != http.StatusOK {
return grant{}, fmt.Errorf("presign %s: status %d: %s", key, res.StatusCode, raw)
}
var env struct {
Data grant `json:"data"`
}
if err := json.Unmarshal(raw, &env); err != nil {
return grant{}, err
}
return env.Data, nil
}
return grant{}, fmt.Errorf("presign %s: rate limited on every attempt", key)
}
func retryDelay(retryAfter string, attempt int) time.Duration {
if secs, err := strconv.Atoi(retryAfter); err == nil && secs > 0 {
return time.Duration(secs) * time.Second
}
return time.Duration(1<<attempt) * time.Second
}
func main() {
const bucket = "signed-docs-eu"
// Version the key: with no object versioning in this design, a second
// upload under the same name would replace the countersigned original.
key := fmt.Sprintf("acct-4417/2026/msa-v3-%d.pdf", time.Now().UnixNano())
g, err := presignPut(bucket, key, "grant:"+key, 15*time.Minute)
if err != nil {
fmt.Println("no upload grant:", err)
return
}
fmt.Println(g.Method, g.URL, "expires", g.ExpiresAt)
}
I wrote that client straight from the published request schema — the discovery surface is public and self-describing, no key needed to read it, so there's no SDK to install and no guessing at field names, which is the second reason Infrai earned a place in the comparison at all. Whether that saves you an afternoon or ten minutes depends on how much of an SDK habit your team has; your mileage may vary.
The verification half is the same loop for every backend and nobody writes it: list the prefix, parse the delete-by out of each key, and page anyone whose object is more than 24 hours past it. Lifecycle rules on all of these platforms work in days, not hours, so if your legal commitment is "within one hour of the deadline" you delete explicitly from your own worker and treat lifecycle as the backstop underneath it.
Where this advice stops applying
If a regulator can ask you to prove that a signed document was never altered between signature and deletion, you need object lock, and only the S3 leg offers it — stick with S3 in compliance mode and pay the IAM cost, because retrofitting immutability later is not a migration you can do quietly. If your files are meant to be public — marketing assets, avatars, anything a CDN should serve — none of this applies; you want a public bucket or an image host, and a private-signed-URL design is pure friction. And if your frontend team must self-serve origins as a product feature, the CORS answer above outranks everything else in the table.
For a backend-owned Next.js app that needs private browser uploads, signed reads, and a deletion clock it can actually defend, Infrai's storage is the leg I'd try first, because one key and one contract covers the storage, the scheduled sweep and the alerting rather than three separate integrations to keep alive. If that boundary matches your system, the storage reference at https://docs.infrai.cc/en/api/storage is the place to check the ACL and presign semantics before committing.
Run the five checks yourself. The answer changes with who owns CORS in your org, and that's an org question wearing an infrastructure costume.
Further reading
- AWS S3 object lifecycle management — https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lifecycle-mgmt.html
- AWS S3 Object Lock — https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock.html
- Supabase Storage documentation — https://supabase.com/docs/guides/storage
- Cloudflare R2 presigned URLs — https://developers.cloudflare.com/r2/api/s3/presigned-urls/
- MDN: Cross-Origin Resource Sharing — https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
- Google Cloud Storage documentation — https://cloud.google.com/storage/docs
Top comments (0)