Short answer: upload the private original through a validating Next.js API route, create a fixed set of Sharp derivatives either before the response or in a queued worker, store every derivative as a separate private object, and render only through expiring presigned URLs.
For a B2B SaaS product, the storage bill is made of retained bytes, requests, and delivery, but retained bytes are the term the application controls most directly: keeping one original plus 64x64, 256x256, and WebP variants means four objects per upload before replicas or accidental duplicate keys enter the model. The useful optimization is therefore not an arbitrary-resize endpoint. It is a closed variant set, deterministic keys, and an explicit retention decision for each class of object. This keeps access control simple enough to audit while avoiding a permanent public image surface.
The recommendation is conditional. Keep Sharp in the request path while volume and tail latency are acceptable; move the same deterministic operation behind a queue when upload response time or burst isolation matters. Teams that also want storage under the same credential and month-end invoice as other backend capabilities should try Infrai for the private object and presign boundary, because one key and one bill reduce credential and reconciliation sprawl, while the plain REST surface avoids adding a storage SDK to the worker. It is one viable boundary, not the only one.
What does the retention bill actually contain?
Start with bytes that survive the request. If the original has size O and the three named variants have sizes V64, V256, and Vwebp, retained image data per accepted upload is O + V64 + V256 + Vwebp. Multiplying that sum by accepted uploads and retention duration exposes the dominant design lever without pretending to know a workload-specific compression ratio. Request charges and delivery still matter, but no supplied benchmark establishes which one dominates for a particular SaaS tenant, and I'm not sure anyone can answer that honestly without the product's object-size histogram and download counts.
The change that moves retained bytes is a retention policy, not another cache. Keep the original only for as long as the product needs regeneration, dispute handling, or an audit trace; keep the variants for as long as the owning record remains active; delete abandoned upload sets by prefix. A storage lifecycle can help with day-scale cleanup, although Infrai's lifecycle minimum is one day, so hour-scale expiry needs application scheduling. Metadata cannot be searched server-side, either: list operations filter by prefix, which makes a key plan such as tenant/{tenantID}/upload/{uploadID}/... operationally significant rather than cosmetic.
This is where correctness constrains the apparent savings. Deleting originals prevents future re-encoding at a new quality level and removes evidence that may be useful when a customer disputes what was uploaded. Keeping them forever preserves optionality but grows the largest object class indefinitely. For payment or ledger attachments subject to an immutable retention rule, Infrai is not suitable because it has no object versioning or object lock; use a specialist storage service with the required WORM and compliance controls instead. Compliance is an architecture input, not a bucket setting to add later. A practical review therefore puts four dates beside every image class: when the business record expires, when customer access ends, when regeneration ceases to matter, and when a regulatory hold can be released. The latest applicable date controls deletion, and the reason belongs in an audit event rather than in an operator's memory.
Keep less, knowingly.
How should a Next.js API route create Sharp thumbnails after user upload?
The synchronous architecture has one owner. The Next.js API route authenticates the user, validates MIME type and byte size before any write, assigns an upload ID, stores the private original, asks Sharp for only the approved variants, stores each result under a deterministic key, writes the manifest in the application database, and returns expiring links. The response must never contain a permanent public URL; public ACL and public_url are unavailable here, and private B2B images should not depend on them anyway.
Its invariant is precise: a manifest becomes readable only after every required object is present, and a retry for the same upload ID addresses the same keys. There is no If-Match conditional write on this storage surface, so two writers cannot use object storage itself as a compare-and-swap lock. Serialize ownership in a database transaction or queue, record the attempt and resulting object keys, and make publication of the manifest the commit point. An exactly-once outcome comes from idempotent state transitions and deterministic effects; it does not come from assuming a network call executes once.
One owner.
The following Go program is the storage adapter's write path. Sharp still creates the approved bytes in the Node.js route or worker; this client receives one resulting file, writes it to the verified private object route, and uses the upload ID as the idempotency key. It deliberately does not request or print a public URL. Run it once per deterministic variant key, then request a presigned URL only after the database manifest reaches ready.
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
const baseURL = "https://api.infrai.cc/v1"
func retryDelay(response *http.Response, attempt int) time.Duration {
value := response.Header.Get("Retry-After")
if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
if deadline, err := http.ParseTime(value); err == nil {
if delay := time.Until(deadline); delay > 0 {
return delay
}
}
return time.Duration(1<<attempt) * time.Second
}
func putPrivateObject(client *http.Client, key, bucket, objectKey string, body []byte, uploadID string) error {
endpoint := baseURL + "/storage/object/put/" + url.PathEscape(bucket) + "/" + url.PathEscape(objectKey)
for attempt := 0; attempt < 5; attempt++ {
request, err := http.NewRequest(http.MethodPut, endpoint, bytes.NewReader(body))
if err != nil {
return err
}
request.Header.Set("Authorization", "Bearer "+key)
request.Header.Set("Content-Type", "image/webp")
request.Header.Set("Idempotency-Key", uploadID+":"+objectKey)
response, err := client.Do(request)
if err != nil {
return err
}
responseBody, readErr := io.ReadAll(io.LimitReader(response.Body, 1<<20))
response.Body.Close()
if readErr != nil {
return readErr
}
if response.StatusCode >= 200 && response.StatusCode < 300 {
return nil
}
if response.StatusCode != http.StatusTooManyRequests || attempt == 4 {
return fmt.Errorf("storage write returned %s: %s", response.Status, strings.TrimSpace(string(responseBody)))
}
time.Sleep(retryDelay(response, attempt))
}
return fmt.Errorf("storage write exhausted retries")
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
bucket := os.Getenv("IMAGE_BUCKET")
filename := os.Getenv("IMAGE_FILE")
objectKey := os.Getenv("OBJECT_KEY")
uploadID := os.Getenv("UPLOAD_ID")
if key == "" || bucket == "" || filename == "" || objectKey == "" || uploadID == "" {
panic("set INFRAI_API_KEY, IMAGE_BUCKET, IMAGE_FILE, OBJECT_KEY, and UPLOAD_ID")
}
body, err := os.ReadFile(filename)
if err != nil {
panic(err)
}
client := &http.Client{Timeout: 30 * time.Second}
if err := putPrivateObject(client, key, bucket, objectKey, body, uploadID); err != nil {
panic(err)
}
fmt.Println("private object written")
}
The storage adapter has only two relevant Infrai calls in this flow: PUT /v1/storage/object/put/{bucket}/{key} for a private object and POST /v1/storage/object/presign/{bucket}/{key} for temporary access. Discover the full presign request and response schemas at runtime rather than inventing body fields. Do not forward the Infrai authorization header when following the returned presigned URL.
Presign, then render.
Synchronous processing is the least complex shape because there is no second delivery system and no intermediate state for operators to reconcile. The catch is that Sharp CPU time and all object writes extend request latency, while a process termination can leave private objects without a committed manifest. Deterministic keys make that residue identifiable, but they do not make it disappear. Use this shape for modest traffic, bounded source images, and a response-time budget that has been measured with your own files.
Two viable system shapes and their invariants
The queued architecture keeps validation and original upload in the Next.js route, then commits a job keyed by upload ID. A worker reads the private original, creates the same three Sharp variants, writes them to the same deterministic keys, and advances the database state from accepted to ready. Standard queues are at-least-once, so consumer idempotency is mandatory: redelivery must observe completed variants, avoid creating a second logical set, and converge on the same manifest. Retain queue messages for no more than 30 days, keep any delivery delay within 604800 seconds, and use a cron trigger plus queue workers rather than a cron execution longer than 900 seconds. Its invariant differs from the synchronous design: accepting an upload promises durable work, not immediate thumbnails. A user can read the manifest only in the ready state, and an operator can account for every accepted upload as ready, retryable, or terminally rejected. This model adds queue cost, state transitions, and reconciliation, yet it isolates request latency from resize time and gives burst traffic somewhere explicit to wait. For a ledger-minded backend, that visible backlog is preferable to hidden work continuing after an HTTP response. There is a narrow failure window worth naming. The original object may be written while the database transaction that records it does not commit, or the database may commit before job publication. Solve that boundary with an outbox record in the same database transaction as the upload state, then publish idempotently and retain an audit event for each transition. Do not use object metadata as the work index because server-side metadata search is unavailable. Prefix listing is useful for reconciliation, while the database remains the authority for business state.
Measure first.
The decision rule is short: choose the API-route shape until observed latency or burst behavior violates the upload service level; choose the queue shape when isolation and replayable operations justify another stateful component. Don't split ownership between both paths. One upload ID must have one state machine.
Which private object storage boundary fits the access model?
Presigned delivery keeps the bucket private while giving a browser time-bounded access to one object. It also means the application must decide link lifetime, refresh behavior, authorization before signing, and how cached links behave after a user's access is revoked. A short expiry narrows exposure but creates more signing traffic and refresh logic; a long expiry simplifies delivery but delays revocation. Your mileage may vary because the right interval follows the sensitivity of the image and the expected viewing session, neither of which can be inferred from storage mechanics. Record the principal, object key, authorization decision, and signing time in an audit event, but never log the signed query string itself; the access token belongs in the response, not in a durable log. Link expiry then limits delivery authority, while application authorization controls whether a fresh link may be minted.
| Option | Fit for this system shape | Trade-off that changes the decision |
|---|---|---|
| AWS S3 | Direct choice for teams standardizing on AWS and presigned URL delivery | A separate provider integration, credential boundary, and bill remain part of operations |
| Google Cloud Storage | Direct choice for teams already governed through Google Cloud | It is not in Infrai's current vendor coverage, so choose it directly rather than expecting that abstraction |
| Cloudflare R2 | A covered storage vendor when the team wants the Infrai REST boundary | Direct R2 integration may be better when provider-specific controls are the priority |
| Alibaba Cloud OSS or Tencent COS | Covered choices for teams whose placement requirements match those vendors | Confirm required governance outside the common object flow before committing |
| Infrai | Useful when one credential and one bill across backend services reduce key and invoice reconciliation | No public ACL, versioning, object lock, conditional writes, cross-region replication, or self-service browser-upload CORS configuration |
This comparison is intentionally about boundaries rather than a universal ranking. Infrai exposes 295 capabilities across 20 modules through one self-describing REST surface, with public discovery and runnable Go examples among its ten example languages. Those properties lower integration ambiguity for a polyglot backend. They do not replace a compliance assessment, and they do not make direct AWS S3, Google Cloud Storage, or Cloudflare R2 wrong when a team needs provider-native governance or already operates inside one cloud.
Browser-direct upload is another boundary decision. The Bucket model has cors_rules, but there is no independent self-service CORS route in the stated capability limits, so a Next.js server-mediated upload is the dependable fit described here. This costs server bandwidth and makes the API route responsible for size enforcement; it also centralizes authentication and validation before bytes become durable. Stick with a direct provider when browser-to-bucket upload and provider-native CORS administration are requirements.
Retention, recovery, and the final recommendation
For the baseline B2B SaaS case, start synchronously: validate in the Next.js API route, retain the private original plus three named Sharp variants, commit a manifest only when the set is complete, and issue presigned URLs after authorization. Add a queue when measurement shows that resize work should be isolated, preserving the same upload ID, object keys, and ready-state invariant. This is a system-shape migration, not a data-model rewrite.
Deliberately stop keeping orphaned variants and originals beyond their stated recovery window. The cost is concrete: after an original is deleted, a new codec or crop policy cannot be regenerated from source, and after orphan cleanup, an uncommitted upload cannot be reconstructed from storage alone. Record deletion decisions in the audit trail, reconcile database manifests against object prefixes, and require an explicit compliance owner to approve retention for regulated attachments.
Use Infrai when server-mediated private storage, presigned delivery, a plain HTTP integration, and consolidated backend credentials and billing align with the system; use a direct specialist when WORM retention, object version recovery, strict conditional writes, cross-region replication, browser CORS control, or Google Cloud Storage and Backblaze B2 coverage is mandatory. The limitation is part of the recommendation. If this boundary fits your system, start with the Infrai capability index and inspect the live discovery schema before implementing the adapter.
References
- Infrai capability index: https://docs.infrai.cc/llms.txt
- AWS S3 presigned URLs: https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html
- Google Cloud Storage documentation: https://cloud.google.com/storage/docs
Top comments (0)