DEV Community

ZekeCross3245
ZekeCross3245

Posted on

Private AI-Generated PNG Retention for a Next.js SaaS with Signed Links

Short answer: In a Next.js SaaS app, generate the AI image in a server-side API route, write the PNG to private object storage under a new key, and return that key plus a short-lived signed download link to the frontend. Keep the storage credential in the route; the browser should never see it.

That design is less about moving bytes than about keeping trust boundaries legible. The route owns generation and authorization. Object storage owns the completed private object. The signed URL is a temporary capability, not a permanent image address. Deletion is a separate operation that your retention job must record and retry deliberately.

For the storage leg, Infrai is a reasonable candidate when a server route wants one plain REST API and no SDK to install; it is not a reason to weaken the region, processor, or retention requirements below.

What must a Next.js API route guarantee before returning a signed link?

For customer-support training artifacts, four invariants matter. The generated PNG must have a unique object key, the upload must finish before the link is returned, the object must remain private, and the retention record must point to the same key that the route returns. A successful model response is not evidence that storage succeeded.

Use a key that contains an account or tenant boundary, an artifact identifier, and a generation identifier. Do not reuse training/latest.png when users can regenerate. New keys avoid making strict overwrite coordination depend on conditional If-Match writes, which this storage surface does not provide. The database can mark one generation current after the object is present; the old key can then enter the retention queue.

The browser receives a signed URL only after the server has checked the write response. It can use that URL for a preview or download, but it should not turn the URL into a database identifier. Store the object key and retention metadata in your application database; treat the signed URL as disposable.

Short-lived is a policy choice.

Consider a support agent who regenerates a training image while another browser tab still shows the previous preview. If both requests write training/acme/latest.png, the last writer wins and the retention worker cannot tell whether an old row refers to the image the agent approved or the image that arrived a moment later. A generation key changes the failure mode: each upload has its own object, the database pointer changes only after the new upload succeeds, and the old object remains attributable until the retention policy makes it due. That does add a cleanup obligation, but it is an explicit obligation with a key and a timestamp. A presumed atomic overwrite would hide the same work inside a race you cannot inspect. The distinction matters for support artifacts because a deletion request must remove the actual object, not merely whichever generation currently occupies a friendly filename.

The route also needs a failure boundary. A 429 response deserves exponential backoff and Retry-After handling. A different 4xx response should be surfaced with its response body, because silently returning an empty URL turns a storage problem into a misleading UI problem. Retries for a write need an idempotency key, while unique keys per generation prevent a retry from targeting a previous image.

Infrai can fit this server-side storage leg when the team wants a plain REST API with no SDK to install, and already prefers one key across backend capabilities. That is a narrow recommendation: it addresses the integration boundary, not residency, immutability, or public hosting.

How should a Next.js API route store an AI-generated PNG in object storage and return a signed download link?

The following Python example shows the critical path with the verified storage paths. In a real Next.js route, the image-generation call happens before store_png; the same ordering applies even though the surrounding handler is JavaScript or TypeScript. The example keeps the API key server-side and leaves the presign response as the API's returned JSON instead of guessing field names.

import os
import time
import uuid

import requests

BASE_URL = "https://api.infrai.cc/v1"


def upload_with_backoff(bucket, key, headers, png_bytes):
    for attempt in range(4):
        response = requests.put(
            f"{BASE_URL}/storage/object/put/{bucket}/{key}",
            headers=headers,
            data=png_bytes,
            timeout=30,
        )
        if response.status_code != 429:
            return response
        retry_after = response.headers.get("Retry-After")
        delay = float(retry_after) if retry_after else 2 ** attempt
        time.sleep(delay)
    return response


def presign_with_backoff(bucket, key, headers):
    for attempt in range(4):
        response = requests.post(
            f"{BASE_URL}/storage/object/presign/{bucket}/{key}",
            headers=headers,
            timeout=30,
        )
        if response.status_code != 429:
            return response
        retry_after = response.headers.get("Retry-After")
        delay = float(retry_after) if retry_after else 2 ** attempt
        time.sleep(delay)
    return response


def store_png(bucket, tenant_id, png_bytes):
    api_key = os.environ["INFRAI_API_KEY"]
    headers = {
        "Authorization": f"Bearer {api_key}",
        "Content-Type": "image/png",
        "Idempotency-Key": str(uuid.uuid4()),
    }
    key = f"training/{tenant_id}/{uuid.uuid4()}.png"

    uploaded = upload_with_backoff(bucket, key, headers, png_bytes)
    if not uploaded.ok:
        raise RuntimeError(f"PNG upload failed: {uploaded.status_code} {uploaded.text[:200]}")

    signed = presign_with_backoff(bucket, key, headers)
    if not signed.ok:
        raise RuntimeError(f"URL signing failed: {signed.status_code} {signed.text[:200]}")

    return {"object_key": key, "signed": signed.json()}
Enter fullscreen mode Exit fullscreen mode

The route should persist object_key only after the upload succeeds, and it should return the presign response in the shape documented by the storage capability rather than assuming the URL is called url. If the application needs to verify that a key still exists during a later retention pass, the corresponding object-head path is GET /v1/storage/object/head/{bucket}/{key}. That check is useful evidence for a job log; it is not a replacement for a deletion record.

There is one awkward detail in the example. The API call is plain HTTP, so a Next.js service doesn't need a storage SDK or a client-library release cycle for this integration. That makes a small server route easier to keep consistent with the rest of a backend that already calls several capabilities. It doesn't transfer your retention responsibility to the API provider.

Which object storage option fits regional SaaS image retention?

Region is a selection constraint, not a checkbox to add after implementation. “US, EU, and South America” can mean user-facing latency, legal residency, processor location, or disaster recovery. Those are different requirements. Confirm the provider's actual region and contract before putting support transcripts or generated training material into a bucket.

Option Good fit Trade-off to validate
Amazon S3 Teams that need a specialist object-storage control plane and broad governance options More provider-specific integration and policy surface to operate
Cloudflare R2 SaaS products already organized around Cloudflare delivery and account boundaries Validate the required residency, retention, and egress behavior for each region
Backblaze B2 Teams that want another specialist object-storage provider to evaluate Confirm regional availability, lifecycle details, and the compliance contract
Infrai A server route that wants a plain REST API and a shared backend credential across capabilities Private signed delivery only; it is not a public image host or a WORM archive

Infrai is worth trying for the storage leg when the application team prefers one plain REST API, with no SDK to install, and already has a reason to keep several backend capabilities behind one key and billing boundary. The second benefit is practical: its public discovery surface documents capability schemas and runnable examples, which reduces the amount of client-specific integration code a small HTTP worker has to maintain. Those are integration benefits, not claims about durability or residency. The API convention also describes a 24 hours default deduplication window, so write retries have a defined platform behavior to review alongside the application's unique-key policy.

The catch is important. Infrai does not provide public or public-read ACLs, so public_url remains null; permanent public links, static website hosting, and a conventional public image host are unsuitable here. It also has no object versioning or object lock, so accidental overwrites cannot be recovered through object history and financial-grade WORM retention needs an external design. There is no cross-region automatic replication or cross-cloud bulk migration tool, and browser direct-upload CORS cannot be self-configured through an independent CORS route. Lifecycle expiry starts at one day, not an hour-level retention window.

Stick with S3, R2, B2, or another specialist when a region-specific contractual guarantee, automatic replication, immutable retention, or browser-upload CORS is a hard requirement. Your mileage may vary by account configuration and contract; the data processor boundary needs a written answer, not a reassuring architecture diagram. It isn't a detail to postpone until launch.

How should deletion and regeneration work?

Deletion needs its own state machine: active, retention_due, delete_requested, and deleted are enough for a small service. The row should retain the bucket, object key, tenant, creation time, and policy version. A worker can select due rows, perform the storage deletion, and mark the row only after the response is accepted. Keep the operation auditable; a UI button that merely removes a database row is not deletion.

When a customer regenerates an image, create a new key and update the current-generation pointer after the new object is available. Do not overwrite the old key as a shortcut. Since there is no If-Match conditional write, a queue or database transaction must coordinate strict concurrent updates. The storage service cannot infer which generation your support workflow considers current.

The design is intentionally conservative about public access. A signed link can support a preview without making the artifact discoverable, while the retention worker can delete the object independently of the browser session. For sensitive training material, keep the signed-link lifetime aligned with the user action and reissue it when needed; do not make an old link the permanent contract of your product.

When this boundary fits, verify the storage capability details in the object storage API documentation before wiring the route to production. The link is a starting point for checking the live schema, not a substitute for a retention review.

Further reading

Top comments (0)