Skip to main content

AI-Generated Images

3 min read

An image that a model just produced, kept so the user can see it again tomorrow. Providers hand back a temporary URL or a response you can only read once, so the bytes have to land somewhere of your own before they expire.

Three choices make that work:

  • A server-side write. The bytes are already on your server when the model answers, so they go up with put. There is no browser upload here.
  • A new path per generation. generations/${user.id}/${generationId} is written once and never overwritten, so cache: 'immutable' is unconditionally safe.
  • A row per generation. It holds the prompt, the path and the URL, and it is what the gallery reads.

This recipe uses a public bucket. If you have not created one yet, start with the Quickstart. A private gallery is covered at the end.


Storing a generation#

Read the model's response as a Blob and hand it to put. A Blob carries its own length and its own type, so nothing has to be declared.

lib/generations.ts

The path has no extension on purpose: the object is stored and served as whatever type the provider answered with, image/png or image/webp alike. If the response carries no Content-Type, pass contentType yourself, or the object is stored as application/octet-stream and a browser downloads it instead of rendering it.

Plenty of providers answer with JSON pointing at a temporary URL instead. Fetch that URL and store what comes back the same way:

Both forms hold the image in memory for the length of the call, which is fine for an image. For a body too big for that, put takes the response stream with a size; see Writing.


The route#

app/api/generate/route.ts

Return your own URL, never the provider's. The provider's link is the one that stops working in an hour.


The bucket cannot answer "what has this user generated". Your table can, so read rows and render the URL stored on each one:

app/gallery/page.tsx

Each object is stored immutable under a path that is never reused, so a browser or CDN that has seen one keeps it for a year and there is nothing to invalidate.

If the gallery is not public, create the bucket as private instead. blob.url is undefined there, so the row keeps only the path, and a route of yours checks ownership and calls signedReadUrl at click time. That shape is in Private documents.


Deleting#

Delete the row first, then the object. The gallery stops showing the image immediately, and if the second step fails the leftover is an object nobody links to rather than a broken image. del treats an already missing object as success, so it is safe to retry.

app/actions.ts

When a user deletes their account, the rows are the only thing that knows the paths, so read them first, delete the objects, then drop the rows. If it fails partway, run it again:


Next steps#