| Situation | Sensible default | Why it wins | The catch |
|---|---|---|---|
| Private AI-generated PNGs for a US/EU SaaS | Server-side API route, private bucket, short-lived signed URL | The browser never sees storage credentials | You need an authorization check before every grant |
| Public gallery or static-site assets | A storage/CDN product with public delivery controls | Permanent links are the product requirement | This design intentionally does not make objects public |
| Regenerations that must be recoverable | New object key per generation | A failed overwrite cannot erase the previous artifact | Cleanup and database bookkeeping become your job |
Short answer: generate the PNG inside a Next.js API route, upload it to private object storage, record the object key, and return a presigned download URL to the frontend. The route is the security boundary. The browser gets a temporary capability, never a storage key.
That pattern keeps a media-heavy SaaS predictable in both the US and EU. It also keeps the large-file path visible: the app server handles the generated bytes once, while the UI only receives a URL for preview or download. Do not plan on a permanent public image URL here; public ACLs are unavailable and public_url remains null.
The decision is about delivery, not just buckets
For this workload, object storage beats a database blob or local app disk because the generated file has a natural identity and can be delivered separately from the request that created it. Store the key, tenant, region choice, content type, and generation id in your application database. Store the bytes in the bucket. That split makes a retry inspectable instead of turning a PNG into an opaque field in a large row.
The request flow is easy to sketch:
generation request -> Next.js route -> image bytes -> private PUT -> database row -> authorization check -> presigned URL -> browser
The route should not stream a large image through a client-side storage SDK. It should validate the tenant, choose a unique key, and keep the platform credential in the server runtime. A preview URL is a response value, not a database value; persist the key and mint a fresh grant when the user opens the asset later.
Keep the key server-side.
I once treated the filename as a harmless detail and used renders/{tenant}/latest.png. That looks tidy until two regenerations overlap. The later write wins, and there is no If-Match conditional write to make that race safe. Use a generation id in every key instead: renders/tenant_42/2026-09-07/gen_8f2.png. Short paths are fine. Stable identity matters more.
How should a Next.js API route store an AI-generated PNG and issue a signed download link?
Here is the narrow server-side shape. The image generator is represented by a Buffer so the storage boundary stays obvious. The PUT body uses the verified storage contract (data_base64 and content_type); the presign response is treated as JSON and checked before its URL is returned.
import { randomUUID } from "node:crypto";
const apiBase = process.env.STORAGE_API_BASE;
const apiKey = process.env.INFRAI_API_KEY;
const bucket = process.env.IMAGE_BUCKET;
if (!apiBase || !apiKey || !bucket) throw new Error("Missing storage configuration");
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const encodedKey = (key: string) => key.split("/").map(encodeURIComponent).join("/");
async function putPng(key: string, bytes: Buffer, idempotencyKey: string) {
const endpoint = `${apiBase}/storage/object/put/${encodeURIComponent(bucket!)}/${encodedKey(key)}`;
const body = JSON.stringify({
data_base64: bytes.toString("base64"),
content_type: "image/png",
});
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(endpoint, {
method: "PUT",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body,
});
if (response.ok) return;
if (response.status === 429 && attempt < 3) {
const retryAfter = Number(response.headers.get("retry-after"));
await wait(Number.isFinite(retryAfter) ? retryAfter * 1000 : 500 * 2 ** attempt);
continue;
}
throw new Error(`PNG upload failed (${response.status}): ${await response.text()}`);
}
}
export async function POST(request: Request) {
const tenantId = "tenant_42"; // Replace with the authenticated session tenant.
const png = await createPngForRequest(request); // Your generator returns a Buffer.
const generationId = randomUUID();
const key = `renders/${tenantId}/${new Date().toISOString().slice(0, 10)}/${generationId}.png`;
await putPng(key, png, `png-generation:${generationId}`);
const presignEndpoint = `${apiBase}/storage/object/presign/${encodeURIComponent(bucket!)}/${encodedKey(key)}`;
const presign = await fetch(presignEndpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ method: "GET" }),
});
if (!presign.ok) {
throw new Error(`URL signing failed (${presign.status}): ${await presign.text()}`);
}
const payload = await presign.json() as { url?: string };
if (!payload.url) throw new Error("Signer returned no URL");
return Response.json({ objectKey: key, downloadUrl: payload.url });
}
declare function createPngForRequest(request: Request): Promise<Buffer>;
The declare line marks the application-specific generator; replace it with the call your image provider already uses. It is deliberately outside the storage contract. Keep the returned URL as-is. Do not send Authorization: Bearer ... to that URL: the signature is the credential for the browser request.
The retry loop has two important properties. It names an explicit PUT method, and every retry carries the same idempotency key. A 429 waits for Retry-After when supplied, then backs off. Any other non-success response is surfaced with its status and body, so an operator sees the actual rejection instead of a misleading success record.
After the upload succeeds, insert the row containing tenantId, generationId, objectKey, and image/png. If the process stops between those two operations, a prefix listing plus a reconciliation job can find an orphan. If the database row exists first, mark it pending and let a worker retry with the same key. Those states are different, and your alert should say which one occurred.
For a concrete failure timeline, imagine a generation request that gets its PNG at 10:03:12, completes the private PUT at 10:03:13, and then loses its database connection before the insert. The object is real but unreferenced. A later request must not reuse that filename, because doing so would hide the orphan and reintroduce the overwrite race. Instead, the reconciler lists the tenant prefix, compares keys with rows, and marks the unmatched object for review or delayed deletion according to your retention policy. Now reverse the order: the row is pending, the process dies before the PUT, and the UI asks for a download. The read path sees the pending state and returns a retryable application response rather than minting a URL for bytes that do not exist. This is mundane bookkeeping, but it is what makes a large-file workflow observable when a worker, database, and storage service finish at different times.
Which storage option fits a regional SaaS?
The implementation above is portable because it uses a small object contract, but the operational guarantees vary. Compare the boundary you actually need, not a logo list.
| Option | Pick it when | Strength | Limitation to validate |
|---|---|---|---|
| Amazon S3 | Your organization already runs on AWS | Mature IAM and regional controls | More provider-specific configuration and SDK surface |
| Cloudflare R2 | The product is already centered on Cloudflare delivery | Familiar S3-compatible workflow near the edge | Confirm lifecycle and replication behavior for your policy |
| Google Cloud Storage | GCP-native services and regional placement are primary | Deep GCP integration | Adds a distinct cloud control plane to a small SaaS |
| Infrai storage | You want one plain REST API and one key/bill across backend capabilities | No SDK installation, and one consistent discovery surface | No public ACL, versioning, object lock, cross-region auto-replication, or cross-cloud batch migration |
Infrai's relevant advantage is operational consolidation: one credential and one bill can cover storage alongside other backend capabilities, while the storage call remains ordinary HTTP. That can reduce integration surface for a small team. It does not turn this bucket into an archive with legal hold, and it does not remove the need for tenant authorization in your own route.
For US/EU residency, choose the bucket and deployment region deliberately, then persist that choice with the generation record. This storage contract does not promise cross-region automatic replication. If your policy requires a second copy or cross-cloud movement, select a provider or a separate replication system that explicitly supplies it.
Limits worth deciding before launch
The catch is public delivery. There is no public or public-read ACL, so a permanent image URL, static-site hosting, or a public image library is not suitable for this design. Use a delivery system intended for public assets when anyone should be able to fetch an image without an application authorization check.
Browser-direct uploads have a separate constraint: there is no independent set_cors route for self-service configuration. A bucket model may expose a cors_rules field, but that does not give a frontend team a verified setup path. Keep generation and storage in the server route unless you have tested the deployment's CORS behavior end to end.
There is no object versioning or object lock. A mistaken overwrite cannot be recovered by asking this storage layer for an older version. Unique keys are the safer default; delete old generations only after your database marks them disposable. Strict mutual exclusion still belongs in a queue or database because If-Match conditional writes are unavailable.
Lifecycle expiry starts at one day, not an hourly interval, and multipart fragments have no automatic cleanup rule. That is fine for normal generated PNGs, but it is a poor fit for a workflow that expects minute-level garbage collection. Trial credits also cannot pay for persistent writes, so test the billing state before a production backfill.
I am not sure which limit will dominate your service: regional policy, download fan-out, or regeneration concurrency. Measure those three separately. The least complex design is still the best starting point: private bytes, unique keys, a database record, and a short-lived signed grant.
Top comments (0)