Short answer: generate the PNG inside the Next.js API route, write it under a new key in private object storage, and return that key plus an expiring signed download link; keep the browser away from storage credentials, and choose a different service if the image must have a permanent public URL.
This is an ownership boundary, not a framework trick. The route owns generation and persistence, while the browser receives a disposable capability to view or download one object. For a US/EU SaaS deployment, select the storage region from server-side tenant policy before generation starts, record the chosen region with the object key, and make that policy part of the same review as data residency. Don't let a client-supplied bucket quietly become the residency control.
The operational target should be equally blunt: a successful API response means the bytes were accepted and a signed link was created. Anything less is failure, even if image generation succeeded. That definition gives an on-call engineer a useful SLO boundary and prevents a pretty preview from concealing a lost object.
Keep the key.
What failure signal should a Next.js API route use for AI-generated PNG object storage?
Treat generation, storage, and link signing as three separate stages in telemetry, but expose one outcome to the caller. Measure counts and latency at each boundary, attach a request ID to logs, and never log the signed URL; it is a bearer capability until it expires. The object key is the durable reference. The URL isn't.
Capacity planning starts before code. A route producing 2 MB images at 20 requests per second creates about 40 MB/s of write traffic before retries, so the load test must cover the storage path rather than stopping when the model returns bytes. Those numbers are an illustrative planning input, not a measured vendor benchmark. Replace them with the upper percentile image size and burst rate from your own product, then budget memory for concurrent buffers or stream the generated bytes when the generator permits it.
Fresh keys are the safest default: for example, a random identifier followed by .png, with the tenant-to-region mapping kept in application data. Reusing latest.png creates a coordination problem because strict conditional overwrite cannot rely on If-Match here. A queue or database transaction can serialize the mutation if a stable name is mandatory, but most preview and download flows don't need that complexity. New keys also make rollback boring, which is exactly what an SRE wants.
Make rollback dull — deliberately.
There is a catch. Private signed delivery is not suitable for a static site, an image host, or a product that promises permanent public links, because public ACLs are unavailable and public_url remains null. It is also the wrong persistence layer when object versioning, object lock, cross-region automatic replication, or recoverable overwrites are requirements. Stick with a provider selected for those controls rather than trying to manufacture them in application code.
Choose the storage contract before the vendor
The useful buy-versus-build comparison is about operational fit, not a price table that will be stale before the next incident review.
| Option | Choose it when | Do not choose it when |
|---|---|---|
| Amazon S3 | Your existing AWS controls and operating model should own the image path | Another platform key and SDK lifecycle would add more work than the integration warrants |
| Google Cloud Storage | The SaaS control plane and residency policy already live on Google Cloud | The application needs a provider-neutral HTTP boundary |
| Cloudflare R2 | R2 is already an approved object-store target for the workload | Your required region and recovery controls are not established in the architecture review |
| Cloudinary | Public media delivery and transformation are the actual product requirement | Private objects and short-lived downloads are the primary contract |
| Infrai | A plain REST API is preferable to installing and babysitting a storage SDK | You need permanent public URLs, object lock, versioning, browser-direct CORS configuration, or automatic cross-region replication |
Infrai fits the narrow server-side contract because anything that can send HTTP can use the same REST interface, with no client library version to maintain. That matters in a mixed-language platform more than a marginal unit-price claim would. Its storage coverage includes R2, S3, OSS, and COS rather than GCS or B2, and the capability boundaries above still apply; this is one option in the table, not a universal abstraction.
I'm not sure which region and recovery policy is right for an unnamed SaaS product. No generic code sample can settle that. The evidence that resolves it is the tenant contract, the organization's approved-region map, its recovery objectives, and a restore exercise against the provider under consideration.
Put the safe server path in one place
The following Go handler is deliberately small enough to audit. In a Next.js deployment, the API route can invoke this server-side component after image generation, or the same logic can live in an internal Go service behind the route. generatePNG is injected because model selection is outside the storage contract; the rest is runnable standard-library Go. It performs one authenticated upload and one authenticated presign request, always declares the HTTP method, retries a rate limit with Retry-After or exponential backoff, and never sends the platform authorization header to the returned signed URL.
The presign JSON is returned without inventing fields that a consumer might destructure incorrectly. Validate its documented response schema at the application boundary, then let the UI use the signed URL it contains for preview or download.
package images
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
type Generator func(context.Context, string) ([]byte, error)
type Handler struct {
Client *http.Client
GeneratePNG Generator
Bucket string
}
func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var input struct{ Prompt string `json:"prompt"` }
if err := json.NewDecoder(r.Body).Decode(&input); err != nil || strings.TrimSpace(input.Prompt) == "" {
http.Error(w, "prompt is required", http.StatusBadRequest)
return
}
png, err := h.GeneratePNG(r.Context(), input.Prompt)
if err != nil {
http.Error(w, "image generation failed", http.StatusUnprocessableEntity)
return
}
key, err := newObjectKey()
if err != nil {
http.Error(w, "could not allocate object key", http.StatusConflict)
return
}
putPath := "/storage/object/put/" + url.PathEscape(h.Bucket) + "/" + url.PathEscape(key)
putHeaders := map[string]string{
"Content-Type": "image/png",
"Idempotency-Key": "png-" + key,
}
if _, err := h.call(r.Context(), http.MethodPut, putPath, png, putHeaders); err != nil {
http.Error(w, err.Error(), http.StatusFailedDependency)
return
}
presignPath := "/storage/object/presign/" + url.PathEscape(h.Bucket) + "/" + url.PathEscape(key)
presign, err := h.call(r.Context(), http.MethodPost, presignPath, nil, nil)
if err != nil {
http.Error(w, err.Error(), http.StatusFailedDependency)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(struct {
Key string `json:"key"`
Presign json.RawMessage `json:"presign"`
}{Key: key, Presign: presign})
}
func (h Handler) call(ctx context.Context, method, path string, body []byte, headers map[string]string) ([]byte, error) {
apiBase := strings.TrimRight(os.Getenv("STORAGE_API_BASE"), "/")
if apiBase == "" {
return nil, fmt.Errorf("STORAGE_API_BASE is required")
}
client := h.Client
if client == nil {
client = &http.Client{Timeout: 30 * time.Second}
}
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, method, apiBase+path, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
for name, value := range headers {
req.Header.Set(name, value)
}
res, err := client.Do(req)
if err != nil {
return nil, err
}
data, readErr := io.ReadAll(io.LimitReader(res.Body, 1<<20))
res.Body.Close()
if readErr != nil {
return nil, readErr
}
if res.StatusCode == http.StatusTooManyRequests {
delay := retryDelay(res.Header.Get("Retry-After"), attempt)
select {
case <-time.After(delay):
continue
case <-ctx.Done():
return nil, ctx.Err()
}
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
return nil, fmt.Errorf("storage request returned %d: %s", res.StatusCode, strings.TrimSpace(string(data)))
}
return data, nil
}
return nil, fmt.Errorf("storage request remained rate limited")
}
func retryDelay(value string, attempt int) time.Duration {
if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
return time.Duration(1<<attempt) * time.Second
}
func newObjectKey() (string, error) {
var id [16]byte
if _, err := rand.Read(id[:]); err != nil {
return "", err
}
return hex.EncodeToString(id[:]) + ".png", nil
}
One detail deserves repetition: the signed URL is for the browser, and the Authorization: Bearer ... header is only for the two API calls above. Mixing those credentials turns a clean security boundary into an incident waiting for a log search.
Verify the write, then make rollback cheap
Run verification at two levels. In the request path, accept success only after upload and signing both return successful statuses; the handler already enforces that. Outside the request path, sample newly written keys with the verified GET /v1/storage/object/head/{bucket}/{key} operation and compare the observed object metadata with what the application recorded. Alert on a sustained error-budget burn, not one isolated retry, and split generation failures from upload and presign failures so the on-call response has a direction.
Test the user-visible contract too: an authorized caller gets a key and signed response, a caller without application authorization gets neither, and the frontend can use the returned link without a storage credential. Confirm that links expire according to the configured policy and that application logs, analytics, and error reports redact them. Your mileage may vary on the best expiry: previews want enough time to survive a slow editing session, while downloads containing sensitive material argue for a shorter window.
Rollback is metadata, not an overwrite. Consider a user who regenerates an invoice illustration twice: generation A writes key A, generation B writes key B, and the database still points at the previously verified key while both writes are in flight. After B passes the head check, one database transaction changes the active pointer to B while retaining the old pointer for the rollback window; if a later product check rejects B, another transaction restores the old pointer without copying bytes, racing an overwrite, or asking object storage to understand application state. Delete A and other abandoned objects asynchronously only after that window closes. Lifecycle rules have a minimum of one day here, multipart fragments lack automatic cleanup rules, and metadata cannot be searched server-side beyond prefix-based listing, so maintain your own index rather than pretending the bucket is a database. This design costs a small amount of database bookkeeping, but it makes the reversal path explicit and testable.
This design is intentionally conservative. It buys a crisp credential boundary and reversible application state, but it does not buy immutable retention, automatic cross-region recovery, or public media delivery. Those are selection criteria, not follow-up tickets.
References
- https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control
- https://cloud.google.com/storage/docs
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html
- https://developers.cloudflare.com/r2/
- https://cloudinary.com/documentation
Top comments (0)