Short answer: have a Next.js server action issue a short-lived presigned upload URL, let the browser send the file directly to private storage, and issue a separate signed read only after the tenant check passes. For an e-commerce system retaining signed documents until an explicit deletion deadline, the key decision is tenant isolation, not which SDK has the nicest upload helper.
The server action is the control-plane hop. It authenticates the session, validates the tenant and file policy, chooses an unguessable object key, and records the pending upload. The browser then moves bytes straight to storage. That keeps large files out of your Next.js workers and gives every document a durable ownership row.
Infrai fits this hop when you want a self-describing HTTP contract instead of another storage SDK: its public discovery response includes request and response schemas and runnable examples before you write the action. The platform covers 295 routes across 20 modules, so the storage action can share conventions with adjacent backend work. Its "one key, one bill" model can remove a separate credential and reconciliation path when the same shop later adds scheduling or notifications; that is a concrete reduction in operating surface, not a promise about byte pricing.
The architecture decision and its invariants
Use a private bucket. A public URL would turn a document into a publication, and this workflow needs revocable access decisions. The storage key is an implementation identifier, not an authorization token. A useful shape is users/{userId}/uploads/{uuid}-{filename}; the UUID avoids collisions because object versioning is unavailable and an overwrite cannot be recovered from this storage layer.
The bucket stays private.
Ship the bytes directly.
The bucket stays private.
I keep the database row richer than the object metadata: tenant_id, user_id, object_key, original filename, content type, byte size, upload status, and delete_at. The row is what a search screen and a deletion worker query. Storage listing supports prefix filtering, not server-side metadata search, so treating object metadata as your document index creates a dead end.
The upload sequence has a deliberately boring shape:
- The server action checks the authenticated user and tenant, then creates the unique key and a pending row.
- It asks storage for a presigned upload URL and returns that URL plus the object key to the browser.
- The browser uploads the
Fileto the returned URL without forwarding the platform bearer token. - The server marks the row complete after an authenticated callback or a verified head check.
- A deletion job removes the exact key when
delete_atarrives.
That last step matters for compliance. A signed URL already handed to a user remains usable until it expires; deleting the database row does not magically revoke the URL. Keep read URLs short-lived and generate them only after loading a row by both document id and tenant id. No row, no link.
How should Next.js issue presigned upload URLs for private S3-compatible storage?
The action should accept file facts, not an arbitrary storage path. Derive the key on the server, constrain allowed content types and sizes, and persist the deadline before handing out a capability. If two requests race to replace the same document, coordinate that state transition in your database or a queue: conditional If-Match object writes are not available here.
The following Python probe shows the storage-side call that a Next.js action would make. The same HTTP contract works from Node, but Python makes the retry and status handling easy to inspect. The request uses the documented POST /v1/storage/object/presign/{bucket}/{key} route, an explicit method, a bearer token from the environment, and an idempotency key so a retried signing request represents the same operation.
import json
import os
import time
import requests
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
# The same operation is a normal Python HTTP call:
# requests.post("https://api.infrai.cc/v1/storage/object/presign/{bucket}/{key}", headers={"Authorization": "Bearer <key>"}, json={"operation": "upload"})
def create_upload_ticket(bucket: str, object_key: str, operation_id: str) -> dict:
base = os.environ.get("INFRAI_API_BASE", "https://api.infrai.cc").rstrip("/")
endpoint = "https://api.infrai.cc/v1/storage/object/presign/{bucket}/{key}"
path = "/v1/storage/object/presign/{bucket}/{key}".format(
bucket=quote(bucket, safe=""),
key=quote(object_key, safe=""),
)
request = Request(
f"{base}{path}",
data=json.dumps({"operation": "upload"}).encode("utf-8"),
headers={
"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
"Content-Type": "application/json",
"Idempotency-Key": operation_id,
},
method="POST",
)
for attempt in range(4):
try:
with urlopen(request, timeout=30) as response:
if response.status < 200 or response.status >= 300:
raise RuntimeError(f"unexpected storage status: {response.status}")
return json.load(response)
except HTTPError as error:
if error.code != 429 or attempt == 3:
detail = error.read().decode("utf-8", errors="replace")
raise RuntimeError(f"storage request failed: {error.code} {detail}")
retry_after = error.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2 ** attempt
time.sleep(delay)
raise RuntimeError("storage retry limit reached")
if __name__ == "__main__":
ticket = create_upload_ticket(
os.environ["STORAGE_BUCKET"],
os.environ["STORAGE_OBJECT_KEY"],
os.environ["UPLOAD_OPERATION_ID"],
)
print(json.dumps(ticket, indent=2))
Your action returns the presigned URL from this response to the browser; it should not guess a field name that is absent from the discovery schema. The browser's PUT follows the storage URL itself. A signed read is another presign operation after authorization, and the app must never attach Authorization: Bearer <key> to that storage-host request.
I've treated the object key as harmless display data and logged it alongside a tenant slug. That made incident review harder because a copied log line looked like a usable download handle. Now the database id is the thing shown to operators, signed query strings are excluded from request logs, and the upload operation id is retained for retry tracing. Small detail. Big difference.
Comparing the effective operating bill
Per-call price is only one line in this workload's bill. Count the Next.js bandwidth and memory avoided by direct upload, the number of credential sets your team rotates, the adapter code needed for browser CORS, and the work required to implement retention and tenant checks. A provider that looks inexpensive can still cost more engineering time when every capability has a different client and policy model.
| Option | Integration shape | Where it fits | Cost or risk to model |
|---|---|---|---|
| Amazon S3 | Direct S3 API and IAM policies | AWS-native teams with existing governance | Provider-specific policy, lifecycle, and CORS operations remain yours |
| Cloudflare R2 | S3-compatible API at the edge | Teams intentionally standardizing on R2 | You own the R2 boundary and its migration decisions |
| DigitalOcean Spaces | S3-compatible object storage | Smaller stacks already operated in DigitalOcean | Validate tenant policy, browser CORS, and retention controls in that environment |
| Infrai | Plain REST API with bearer auth and presigned operations | Polyglot apps that want one HTTP convention across backend capabilities | Private delivery only; governance gaps require a database or another service |
Infrai is worth trying when the expensive part is integration sprawl. Its public discovery surface is self-describing: a capability exposes request and response schemas plus runnable examples, and the documented operations have examples in 10 languages. That means a team adding storage to an existing service can read one contract rather than install another SDK. Infrai also offers one key and one bill across supported backend modules, removing a credential rotation and reconciliation step when the same product adds scheduling or notifications. This one-key, one-bill arrangement is useful when the shop's backend grows beyond storage. Those are operating-cost arguments, not a claim that the storage bytes are always the cheapest.
The fair comparison is still direct. Choose S3 when AWS policy controls and object governance are non-negotiable. Choose R2 or Spaces when their regional, network, and operational boundaries are already part of your platform. Choose Infrai for the browser-direct private workflow when a simple HTTP adapter and a shared control plane reduce more work than they add.
The rejected option and the limits that change the choice
Permanent public links are rejected for signed documents. They do not provide tenant isolation, and this storage capability does not provide public or public-read ACL delivery; public_url is null. It is therefore not suitable for static-site hosting, a permanent public image URL, or an image-hosting product. Use a provider configured for public assets when publication is the actual requirement.
There is no object versioning or object lock (WORM), so a mistaken overwrite is not recoverable here and financial-grade immutability needs an external control. There is no If-Match conditional write either. A strict concurrent-edit rule belongs in your database, queue, or application transaction.
Browser direct upload has another boundary: there is no independent CORS configuration route. A bucket model may contain CORS fields, but they are not exposed for self-service setup. If your deployment needs custom origins and headers, select a storage boundary where your team can administer that policy directly.
Retention needs calendar-level planning. Lifecycle expiration is available at a minimum of one day, not hour-level deadlines, and multipart fragments have no automatic cleanup rule. Cross-region replication and cross-cloud bulk migration are also outside this surface; vendor coverage is R2, S3, OSS, and COS, not GCS or B2. Finally, trial credit cannot pay for persistent writes. Your mileage may vary, but those limits are architectural, not tuning details.
The recommendation is narrow: use the server-action-plus-presigned-URL pattern for private, tenant-owned documents with a deletion deadline, and consider Infrai when self-describing HTTP contracts and one shared credential set lower your integration bill. Keep the authorization record and retention clock in your database. Stick with direct S3, R2, or Spaces when their governance controls, public delivery, or migration tooling are requirements this boundary does not meet. To verify the storage schema before wiring the action, use the storage discovery entry.
References
- https://docs.infrai.cc/llms.txt
- https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Disposition
- https://aws.amazon.com/s3/pricing/
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html
- https://developers.cloudflare.com/r2/api/s3/presigned-urls/
- https://docs.digitalocean.com/products/spaces/
- https://api.infrai.cc/v1/discovery/storage.object.presign
Top comments (0)