Provider URLs for generated media expire. A Sora clip, a batch of images, a long audio track — the model hands you a URL that stops working after a while, and once it does the output is gone. To keep the output, save the generated bytes to your own storage and serve them from your own origin, where they outlive the provider's link.
This is a server-side opt-in that layers on top of the generationRuns store Generation persistence already requires. Byte storage adds two more stores, which must be provided together:
What each choice gets you:
memoryPersistence() ships all three stores (generationRuns, artifacts, blobs), so it works out of the box. Any backend that implements ArtifactStore and BlobStore (see Build your own adapter) works the same way.
The bytes land under the blob key artifacts/<runId>/<artifactId>. To fetch a generated file later (to render it, download it, or hand it to another request), add a GET route that reads the artifact back with the retrieveArtifact / retrieveBlob helpers and streams it from your own origin:
// routes/api.generate.image.ts — runs the generation.
import {
generateImage,
generationParamsFromRequest,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiImage } from '@tanstack/ai-openai'
import {
memoryPersistence,
retrieveArtifact,
retrieveBlob,
withGenerationPersistence,
} from '@tanstack/ai-persistence'
const persistence = memoryPersistence()
export async function POST(request: Request) {
const { input, threadId } = await generationParamsFromRequest(
'image',
request,
)
if (typeof input.prompt !== 'string') {
throw new Error('This endpoint accepts text image prompts only.')
}
// Persistence requires the scope these runs are filed under.
if (threadId === undefined) {
return new Response('`threadId` is required', { status: 400 })
}
const stream = generateImage({
adapter: openaiImage('gpt-image-2'),
prompt: input.prompt,
threadId,
stream: true,
middleware: [
withGenerationPersistence(persistence, {
// Stamp the durable serve URL (the artifact route below) onto every
// persisted artifact ref, and rewrite the live result's media field to
// it. Both the live and the restored result then render from your own
// origin instead of the provider's expiring link.
artifactUrl: (ref) =>
`/api/generate/image/artifact?id=${ref.artifactId}`,
}),
],
})
return toServerSentEventsResponse(stream)
}The serve route is a separate route from the generation endpoint. A GET on the generation route is already spoken for by Generation persistence — that is where a reloading client hydrates and where an in-flight run resumes — so the bytes get their own path, the one artifactUrl stamps above:
// routes/api.generate.image.artifact.ts — serves stored bytes by id.
//
// This is a plain file endpoint: it serves one stored file, it does not resume
// a run or rebuild a conversation.
//
// Security: the id comes from the caller, so this route MUST authorize before
// it serves. `ArtifactRecord` carries the `threadId` / `runId` the file was
// generated under — check that against an identity you derive server-side from
// the session, never from the query string. Without this check, any caller who
// learns or guesses an artifact id can read another user's media.
export async function GET(request: Request) {
const artifactId = new URL(request.url).searchParams.get('id')
if (!artifactId) return new Response('missing id', { status: 400 })
const artifact = await retrieveArtifact(persistence, artifactId)
if (!artifact) return new Response('not found', { status: 404 })
// Replace with your session + ownership check, e.g.:
// const user = await auth(request)
// const owned = user != null && (await db.threadOwnedBy(user.id, artifact.threadId))
const owned = true
void request
// 404, not 403 — a distinguishable "exists but forbidden" confirms valid ids.
if (!owned) return new Response('not found', { status: 404 })
const blob = await retrieveBlob(persistence, artifact)
if (!blob) return new Response('not found', { status: 404 })
return new Response(blob.body ?? (await blob.arrayBuffer()), {
headers: {
'content-type': artifact.mimeType,
'content-length': String(artifact.size),
},
})
}memoryPersistence keeps everything in process memory, which is right for development and tests; point generationRuns / artifacts / blobs at a durable backend for production. Control what gets captured with withGenerationPersistence's extractArtifacts (return your own descriptors) and nameArtifact (name each file) options.
By default an artifact's bytes are written under artifacts/<runId>/<artifactId>. Pass storageKey to put them in your own folder structure instead — useful when the bucket is shared with the rest of your app, or when you want media grouped by the thing it belongs to rather than by the run that produced it:
const storageKeyOptions = withGenerationPersistence(persistence, {
storageKey: ({ runId, artifactId, role, name }) =>
`products/${role}/${runId}-${artifactId}-${name}`,
})Two things worth knowing:
The resolved key is recorded on the artifact. Once the path is arbitrary it can no longer be recomputed from the record, so it is stored as ArtifactRecord.blobKey and reads resolve through it. Records written before this existed fall back to the default convention, so adding storageKey to an app with existing artifacts does not orphan them — but it does mean the default convention can never be changed retroactively.
Returning a non-unique key overwrites. Include artifactId, or something equally unique, unless overwriting is what you want.
This is server-side only, deliberately. A key supplied by the browser would be a path-traversal and cross-tenant-write vector.
What gets stored is the generated output. When a provider returns an expiring link, the middleware downloads it and keeps the bytes — that is the whole point of this page.
Prompt media is different, and it splits by how you sent it:
Two reasons a caller-supplied URL is left alone. Downloading it server-side would let anyone name an address your server can reach (cloud metadata endpoints, localhost admin services) and then read the response back through the artifact GET route. The copy is also redundant: whoever supplied the URL already had the media.
If you do need a durable copy of caller-supplied media — a "paste an image URL" input box, say — opt in with allowInputUrl, which is a predicate rather than a flag precisely so the check is not optional:
const inputUrlOptions = withGenerationPersistence(persistence, {
allowInputUrl: ({ url }) => url.hostname.endsWith('.cdn.example.com'),
})Every artifact fetch, input or output, is bounded three ways:
Input fetches add two more: a loopback / private / link-local host block, and a refusal to follow redirects, so a 302 cannot hop somewhere the check never saw.
Treat those as a backstop, not the control: a hostname that resolves to a private address still passes a literal-IP check. Keep allowInputUrl narrow, and for stronger isolation inject artifactFetch to route downloads through an egress-restricted proxy that can check the address actually connected to.
artifactUrl is what makes the stored bytes reachable from the client without any extra plumbing. For each persisted ref it returns the app-origin URL that serves those bytes (the GET route above), and withGenerationPersistence does two things with it:
So the live result already points at your origin, not the provider's expiring link.
Those durable refs ride along on result.artifacts, and they are what a reload restores from. In Generation persistence, the generation hook rebuilds result from the persisted refs on mount, resolving each media field to its durable ref.url — so the restored result renders the same media the live run showed. result.artifacts is the whole artifact surface on the hook: there are no separate top-level artifact fields to read, live or restored.