A generation is one request and one result: an image, a voice clip, a transcript, or a video. It does not keep a conversation.
Every generation uses the same three parts. When you know how to make one image, you know how to make all of them.
You need a React app with a server route, and an OpenAI API key. This takes about 5 minutes.
npm i @tanstack/ai @tanstack/ai-react @tanstack/ai-openaipnpm add @tanstack/ai @tanstack/ai-react @tanstack/ai-openaiyarn add @tanstack/ai @tanstack/ai-react @tanstack/ai-openaibun add @tanstack/ai @tanstack/ai-react @tanstack/ai-openaiOPENAI_API_KEY=sk-...import {
generateImage,
generationParamsFromRequest,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiImage } from '@tanstack/ai-openai'
export async function POST(request: Request) {
const { input, threadId } = await generationParamsFromRequest('image', request)
if (typeof input.prompt !== 'string') {
return new Response('Send a text prompt.', { status: 400 })
}
const stream = generateImage({
adapter: openaiImage('gpt-image-2.5-flare'),
prompt: input.prompt,
threadId,
stream: true,
})
return toServerSentEventsResponse(stream)
}import { fetchServerSentEvents, useGenerateImage } from '@tanstack/ai-react'
export function ImageGenerator() {
const { generate, result, isLoading, error } = useGenerateImage({
connection: fetchServerSentEvents('/api/generate/image'),
threadId: 'image-studio',
})
return (
<div>
<button
onClick={() => generate({ prompt: 'A sunset over mountains' })}
disabled={isLoading}
>
{isLoading ? 'Generating...' : 'Generate'}
</button>
{error && <p role="alert">{error.message}</p>}
{result?.images.map((img, i) => (
<img
key={i}
src={img.url || `data:image/png;base64,${img.b64Json}`}
alt="Generated image"
/>
))}
</div>
)
}Click the button. The image shows up below it.
For a full React and TanStack Start walkthrough with OpenRouter, open Generate Image.
The loop has three parts:
To make a different media type, swap the server function and the hook for another pair from the table. Nothing else changes.
| Activity | Server function | React hook | Guide |
|---|---|---|---|
| Image generation | generateImage() | useGenerateImage() | Image Generation |
| Audio generation | generateAudio() | useGenerateAudio() | Audio Generation |
| Text-to-speech | generateSpeech() | useGenerateSpeech() | Text-to-Speech |
| Transcription | generateTranscription() | useTranscription() | Transcription |
| Voice creation | generateVoice() | - | Voice Creation |
| Summarization | summarize() | useSummarize() | - |
| Video generation | generateVideo() | useGenerateVideo() | Video Generation |
| Live generation | generateLiveVideo() | - | Live Generation |
| World generation | generateWorld() | - | World Generation |
Three types work a little differently:
The hooks for Vue, Solid, Svelte, Angular, and the other frameworks are in Generation Hooks. To keep a result after a page reload, add Generation Persistence.
The quick setup uses a streaming route. You can also call a server function:
| Transport | Use it when | How |
|---|---|---|
| Streaming route | You have an API route. This is the default. | stream: true and toServerSentEventsResponse() on the server. connection: on the hook. |
| Direct | You call a server function and want plain JSON back. No streaming. | Return the result from the function. fetcher: on the hook. |
| Server function that streams | You use TanStack Start server functions and want typed input and streaming. | Return toServerSentEventsResponse(...) from the function. fetcher: on the hook. |
On TanStack Start, the third transport gives you both: the input is typed and the hook still reads a stream. All three are written out in Transports in full.
The server passes stream: true to the generation function and sends the events as SSE. The hook reads them with fetchServerSentEvents(). This is the quick setup above.
The hook sends its input inside an AG-UI request body. generationParamsFromRequest(kind, request) reads that body and returns { input, threadId, runId }. The kind is one of 'image', 'audio', 'tts', 'voice', 'video', 'transcription', 'world', or 'liveVideo'.
The hook calls a server function and gets the result back as JSON. There is no stream.
Server:
import { createServerFn } from '@tanstack/react-start'
import { generateImage } from '@tanstack/ai'
import { openaiImage } from '@tanstack/ai-openai'
export const generateImageFn = createServerFn({ method: 'POST' })
.inputValidator((data: { prompt: string; threadId: string }) => data)
.handler(async ({ data }) => {
return generateImage({
adapter: openaiImage('gpt-image-2.5-flare'),
prompt: data.prompt,
threadId: data.threadId,
})
})Client:
import { useGenerateImage } from '@tanstack/ai-react'
import { generateImageFn } from '../lib/server-functions'
const threadId = 'image-studio'
export function ImageGenerator() {
const { generate, result, isLoading } = useGenerateImage({
fetcher: (input, { threadId }) =>
generateImageFn({ data: { ...input, threadId } }),
threadId,
})
// Render the same way as in the quick setup.
}The server function returns an SSE Response. When a fetcher returns a Response, the hook reads it as an SSE stream. You get typed input from the fetcher and streaming from the response.
Server:
import { createServerFn } from '@tanstack/react-start'
import { generateImage, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiImage } from '@tanstack/ai-openai'
export const generateImageStreamFn = createServerFn({ method: 'POST' })
.inputValidator((data: { prompt: string; threadId: string }) => data)
.handler(({ data }) => {
return toServerSentEventsResponse(
generateImage({
adapter: openaiImage('gpt-image-2.5-flare'),
prompt: data.prompt,
threadId: data.threadId,
stream: true,
}),
)
})Client:
import { useGenerateImage } from '@tanstack/ai-react'
import { generateImageStreamFn } from '../lib/server-functions'
const threadId = 'image-studio'
export function ImageGenerator() {
const { generate, result, isLoading } = useGenerateImage({
fetcher: (input, { threadId }) =>
generateImageStreamFn({ data: { ...input, threadId } }),
threadId,
})
// Render the same way as in the quick setup.
}How the client closes the response:
With stream: true, every generation function returns an async iterable of StreamChunk events instead of a plain result. A successful run sends these events:
1. RUN_STARTED -> the hook sets status to 'generating'
2. CUSTOM -> the hook stores the result
name: 'generation:result'
value: <your result>
3. RUN_FINISHED -> the hook sets status to 'success'If the function throws, the run sends RUN_ERROR instead:
1. RUN_STARTED -> the hook sets status to 'generating'
2. RUN_ERROR -> the hook sets error, and status to 'error'
message: '...'If Generation Persistence stores the generated files, a CUSTOM event named generation:artifacts comes before generation:result. It carries the stored file references.
Chat streaming uses the same event protocol. As a result, toServerSentEventsResponse() and fetchServerSentEvents() work for chat and for generations.
The hook puts a RUN_ERROR on error and sets status to 'error'. Use onError to log it, and show error.message in your UI:
import { fetchServerSentEvents, useGenerateImage } from '@tanstack/ai-react'
export function ImageGenerator() {
const { generate, result, error, status } = useGenerateImage({
connection: fetchServerSentEvents('/api/generate/image'),
threadId: 'image-studio',
onError: (err) => console.error('Generation failed:', err.message),
})
return (
<div>
<button onClick={() => generate({ prompt: 'A sunset over mountains' })}>
Generate
</button>
{status === 'error' && <p role="alert">Error: {error?.message}</p>}
{result?.images.map((img, i) => (
<img
key={i}
src={img.url || `data:image/png;base64,${img.b64Json}`}
alt="Generated image"
/>
))}
</div>
)
}All generation hooks share one set of options and one return shape. The full tables, result transforms, and the framework variants are in Generation Hooks.
flowchart TB
subgraph Server ["Server"]
direction TB
activities["generateImage({ ..., stream: true })
generateSpeech({ ..., stream: true })
generateTranscription({ ..., stream: true })
summarize({ ..., stream: true })
generateVideo({ ..., stream: true })"]
transport["toServerSentEventsResponse()"]
activities --> transport
end
transport -- "StreamChunks via SSE
RUN_STARTED, generation:result, RUN_FINISHED" --> adapter
subgraph Client ["Client"]
direction TB
adapter["fetchServerSentEvents('/api/...')"]
gc["GenerationClient
(state machine)"]
hooks["Framework hooks
useGenerateImage(), useGenerateSpeech(),
useGenerateVideo(), useSummarize(),
useTranscription()"]
adapter --> gc
gc -- "result, isLoading, error, status" --> hooks
endOn the server, every generation is an async function that returns a result. With stream: true, the same function returns a StreamChunk iterable. The client already knows how to read that iterable, because chat uses it too.