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 gets retries and idempotency keys right.

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"
  }
}

Retries, and what the idempotency key promises

object/put accepts an idempotency_key and it means precisely what it says. Replay the same key with the same bytes and you get the original record back — same etag, same created_at, one billable write. Send the same key with different bytes and the API refuses with 409 IDEMPOTENCY_KEY_CONFLICT and a hash_mismatch reason rather than guessing which version you meant.

That’s the right contract for a retried write and the wrong one for a regenerate button, where the user is deliberately asking for different bytes under the same intent. So leave idempotency_key out of generation calls — the API derives one from the content hash anyway — or mint a fresh one per attempt and treat a 409 as “you reused a key”, not as a transient error to retry.

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. POST /v1/storage/bucket/set_cors/{bucket} accepts rules and bucket/get echoes them back, but the storage host answers a real preflight from app.example.com with 403 and no Access-Control-Allow-Origin, so the tab never gets to send the PUT. Browser-direct upload is a real limitation here — if your generator runs client-side and you want the page 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. Each item carries its stored content_type and metadata, so a gallery grid renders straight off the list without a head per row. 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, and billed on the bytes
Signed upload slotOne presign, bytes go directRenders above 1 MBServer-side only — no browser CORS

US, EU, and where the bytes sit

Object storage is provisioned in ap-singapore, and bucket/create tells you so rather than pretending otherwise: send {"region":"eu-central-1"} and the call comes back 400 naming the region it actually serves. So pass ap-singapore or leave the field out entirely.

Worth flagging plainly, because for some readers it settles the question in one line. If EU or US data residency is a promise in your contracts, these objects belong on S3 in the region you promised — that’s a design boundary, not something a retry fixes. The rest of your stack can stay exactly where it is.

What it costs

Verified 27 July 2026: PUT /v1/storage/object/put/{bucket}/{key} is $0.0001 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.

Reads don’t share that unit. GET /v1/storage/object/get/{bucket}/{key} meters at $0.104 per GB — on the bytes that come back, not on the number of requests. Writing a render is a per-call rounding error; serving it is a volume question, which is why the signed-URL row of the table above beats the proxy row on anything a user opens more than once. Get today’s numbers, and today’s units, 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'), c['billing'].get('unit','')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.')]"

Prices move and campaigns run, so treat the figures above as today’s reading and GET /v1/account/usage as the truth for your own account.

The structural argument matters more than the rate. Every step this route handler is about to grow is already on the same key: POST /v1/queue/publish runs the generation job, POST /v1/cron/create sweeps renders older than thirty days, POST /v1/errors/capture records the put that failed, and POST /v1/email/send tells the user their image is ready — no second account, no second vendor, one invoice and per-tenant cost as a single 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