Saving AI-generated PNGs from a Next.js route handler to a bucket

A route handler that writes a generated PNG into private object storage, returns a short-lived link, and survives the retry that silently keeps the old image.

A generated image should leave your Next.js route handler as a URL, never as bytes. The handler writes the PNG into a private bucket, asks for a presigned link, and returns JSON containing that link plus the storage key it wrote. On Infrai that’s two calls — PUT /v1/storage/object/put/{bucket}/{key} with the image as base64, then POST /v1/storage/object/presign/{bucket}/{key} — and only the first one is billable.

Everything after that is layout decisions: how the key encodes the user, how long the link lives, and what happens when the same generation request arrives twice.

The handler

App Router, Node runtime, one POST. The model that produced the PNG isn’t the interesting part here — whatever produced it hands you bytes, and this is what you do with them:

// app/api/images/route.ts
export const runtime = "nodejs";

const API = "https://api.infrai.cc";
const BUCKET = "kb-nextpng-0726";

type StoreResult = { key: string; url: string; expires_at: string };

function keyFor(userId: string, id: string): string {
  const day = new Date().toISOString().slice(0, 10);
  return `gen/${day}/${userId}/${id}.png`;
}

export async function POST(request: Request): Promise<Response> {
  const token = process.env.INFRAI_API_KEY;
  if (!token) return Response.json({ error: "server misconfigured" }, { status: 500 });

  const body = (await request.json()) as { user_id?: string; image_base64?: string };
  if (!body.user_id || !body.image_base64) {
    return Response.json({ error: "user_id and image_base64 are required" }, { status: 400 });
  }
  if (!/^[a-z0-9_]{1,32}$/.test(body.user_id)) {
    return Response.json({ error: "bad user_id" }, { status: 400 });
  }

  const auth = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
  const key = keyFor(body.user_id, crypto.randomUUID());
  const upload = { data_base64: body.image_base64, content_type: "image/png" };
  const link = { op: "get", expires_seconds: 900 };

  try {
    const put = await fetch(`${API}/v1/storage/object/put/${BUCKET}/${key}`, {
      method: "PUT",
      headers: auth,
      body: JSON.stringify(upload),
    });
    const stored = await put.json();
    if (!put.ok || stored.ok === false) {
      return Response.json({ error: stored?.error?.code ?? `put HTTP ${put.status}` }, { status: 502 });
    }

    const signed = await fetch(`${API}/v1/storage/object/presign/${BUCKET}/${key}`, {
      method: "POST",
      headers: auth,
      body: JSON.stringify(link),
    });
    const slot = await signed.json();
    if (!signed.ok || slot.ok === false) {
      return Response.json({ error: slot?.error?.code ?? `presign HTTP ${signed.status}` }, { status: 502 });
    }

    const result: StoreResult = { key, url: slot.data.url, expires_at: slot.data.expires_at };
    return Response.json(result, { status: 201 });
  } catch (err) {
    console.error("image store failed", err);
    return Response.json({ error: "storage unavailable" }, { status: 502 });
  }
}

crypto.randomUUID() in the key is doing real work: it means a re-run never overwrites a previous image, and the path is impossible to enumerate from the outside.

The response your client component gets back looks like this, and the url is safe to drop into a download button:

{
  "key": "gen/2026-07-26/usr_7/scene-01JZ8T4M2Q.png",
  "url": "https://infrai-1333115350.cos.ap-singapore.myqcloud.com/a4ee0c441fa36c267.kb-nextpng-0726/gen/2026-07-26/usr_7/scene-01JZ8T4M2Q.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=900&X-Amz-Signature=ae13e539a0e09a9f2e77036b54fe259b",
  "expires_at": "2026-07-26T01:20:03.893414Z"
}

You can prove the object landed with a free head call — no bytes move, so it costs nothing and tells you the stored MIME type:

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS \
  "https://api.infrai.cc/v1/storage/object/head/kb-nextpng-0726/gen/2026-07-26/usr_7/scene-01JZ8T4M2Q.png" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "found": true,
    "status": "found",
    "key": "gen/2026-07-26/usr_7/scene-01JZ8T4M2Q.png",
    "size_bytes": 70,
    "etag": "2cd8bde463f5d82aae0f0cec061d6b8f",
    "content_type": "image/png",
    "last_modified": "2026-07-26T01:04:59Z"
  }
}

The retry that keeps the old image

Here’s a trap worth knowing before it bites you in production.

object/put accepts an idempotency_key, and when you reuse one the API returns the original record and quietly discards the new bytes. We uploaded VERSION-A under demo-key-0726, then uploaded VERSION-B under the same key: HTTP 200, same etag, same created_at, and reading the object back still gave VERSION-A. That’s correct behaviour for a retried payment, and exactly wrong for a regenerate button where the user expects a different picture. Leave idempotency_key out for generation — the API derives one from the content hash — or make it unique per attempt.

When the PNG is too big for base64

Base64 costs a third more bytes on the wire, and the documented guidance is not to push more than about 1 MB through object/put. A 1024×1024 render usually clears that. Sign an upload slot instead and stream the file straight in from the server:

import { readFile } from "node:fs/promises";

const API = "https://api.infrai.cc";
const BUCKET = "kb-nextpng-0726";
const KEY = "gen/2026-07-26/usr_7/large-render.png";
const SLOT = JSON.stringify({ op: "put", expires_seconds: 900 });

const token = process.env.INFRAI_API_KEY;
if (!token) throw new Error("INFRAI_API_KEY is not set");

const res = await fetch(`${API}/v1/storage/object/presign/${BUCKET}/${KEY}`, {
  method: "POST",
  headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
  body: SLOT,
});
const json = await res.json();
if (!res.ok || json.ok === false) throw new Error(json?.error?.code ?? `HTTP ${res.status}`);

const bytes = await readFile("/tmp/large-render.png");
const upload = await fetch(json.data.url, {
  method: json.data.method,
  headers: { "Content-Type": "image/png" },
  body: bytes,
});
if (!upload.ok) throw new Error(`upload failed: HTTP ${upload.status}`);
console.log("stored", KEY, upload.headers.get("etag"));

That worked in testing from a server process. It will not work from the browser: the signed response carries no Access-Control-Allow-Origin and there’s no route to set bucket CORS rules, so a preflight from app.example.com gets a 403. Browser-direct upload is a real limitation here — if your generator runs client-side and you want the tab to push bytes without touching your server, Cloudflare R2 or S3 with a CORS policy is the honest recommendation.

Keys sorted lexicographically means a per-user prefix lists in a useful order for free:

curl -sS \
  "https://api.infrai.cc/v1/storage/object/list/kb-nextpng-0726?prefix=gen/2026-07-26/usr_7/&limit=50" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

Two things to know about that response. content_type comes back null for listed items even when the object has one — use head when you need it. And next_cursor is the last key of the page, which you pass back as cursor; a gallery that ignores it shows the first page forever.

Three ways to get the image in front of the user

ApproachRoute handler doesGood forLimits
Return the signed URLOne put, one presignDownloads, share buttonsLink expires; always saves rather than previews
Proxy through your appOne object.get per viewSmall images, strict access rulesWhole object buffered in memory, billed per read
Signed upload slotOne presign, bytes go directRenders above 1 MBServer-side only — no browser CORS

US, EU, and where the bytes sit

bucket/create takes a regioneu-central-1, us-east-1 and so on — and stores what you asked for. Worth flagging honestly: when we created a bucket with eu-central-1 and then read a presigned URL back, the host in that URL was an ap-singapore endpoint. If data residency is a contractual promise you’re making to EU customers, verify the presign host yourself before you sign anything, and treat the region field as a request rather than a guarantee.

What it costs

Verified 26 July 2026: storage.object.put is $0.0001 per call and storage.object.get $0.0002 per call, while presign, head, list and every bucket operation are free and rate-limited and don’t touch the $2 in trial credit new accounts start with. Storage rent and egress are metered on top. For a generator producing 10,000 images a month, the put fees are around a dollar and the bandwidth is the real line item. Get today’s numbers straight from the catalogue:

curl -sS https://api.infrai.cc/v1/discovery \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | python3 -c "import json,sys; [print(c['id'], c['billing'].get('price_usd','free')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.')]"

Prices here move downward over time and discount campaigns run, so treat the figures above as an upper bound and GET /v1/account/usage as the truth for your own account.

The structural argument matters more than the rate. The same key that stores the PNG also runs the queue that generates it, the cron that expires old renders, and the email that tells the user it’s ready — one account, one invoice, per-tenant cost as a query. Cloudinary is a better answer if you need on-the-fly resizing, format negotiation and a CDN in front of every asset; that’s a different product and this API doesn’t pretend to compete with it.

References

Browse more storage developer guides