|
| 1 | +--- |
| 2 | +title: 'Generation Hooks: Type-Safe AI Beyond Chat' |
| 3 | +published: 2026-03-11 |
| 4 | +authors: |
| 5 | + - Alem Tuzlak |
| 6 | +--- |
| 7 | + |
| 8 | + |
| 9 | + |
| 10 | +Chat is just the beginning. Most AI-powered apps need to generate images, convert text to speech, transcribe audio, summarize documents, or create videos. Until now, wiring up each of these meant custom fetch logic, manual loading state, bespoke error handling, and a different streaming protocol for every one. |
| 11 | + |
| 12 | +TanStack AI now ships **generation hooks**: a unified set of React hooks (with Solid, Vue, and Svelte coming soon) that give you first-class primitives for every non-chat AI activity. |
| 13 | + |
| 14 | +## One Pattern to Rule Them All |
| 15 | + |
| 16 | +- `useGenerateImage()` for image generation |
| 17 | +- `useGenerateSpeech()` for text-to-speech |
| 18 | +- `useTranscription()` for audio transcription |
| 19 | +- `useSummarize()` for text summarization |
| 20 | +- `useGenerateVideo()` for video generation |
| 21 | + |
| 22 | +Every hook follows the same API surface. Learn one, know them all: |
| 23 | + |
| 24 | +```tsx |
| 25 | +const { generate, result, isLoading, error, stop, reset } = useGenerateImage({ |
| 26 | + connection: fetchServerSentEvents('/api/generate/image'), |
| 27 | +}) |
| 28 | + |
| 29 | +generate({ prompt: 'A neon-lit cyberpunk cityscape at sunset' }) |
| 30 | +``` |
| 31 | + |
| 32 | +`result` is fully typed. `error` is handled. Loading state is tracked. Abort is built in. No boilerplate, no `useEffect` spaghetti, no manual state management. |
| 33 | + |
| 34 | +## Three Ways to Connect |
| 35 | + |
| 36 | +Every generation hook supports three transport modes. |
| 37 | + |
| 38 | +### 1. Streaming (Connection Adapter) |
| 39 | + |
| 40 | +The classic SSE approach. Your server wraps the generation in `toServerSentEventsResponse()`, and the client consumes it through `fetchServerSentEvents()`: |
| 41 | + |
| 42 | +```tsx |
| 43 | +// Client |
| 44 | +const { generate, result, isLoading } = useGenerateImage({ |
| 45 | + connection: fetchServerSentEvents('/api/generate/image'), |
| 46 | +}) |
| 47 | +``` |
| 48 | + |
| 49 | +```typescript |
| 50 | +// Server (API route) |
| 51 | +const stream = generateImage({ |
| 52 | + adapter: openaiImage('gpt-image-1'), |
| 53 | + prompt: data.prompt, |
| 54 | + stream: true, |
| 55 | +}) |
| 56 | +return toServerSentEventsResponse(stream) |
| 57 | +``` |
| 58 | + |
| 59 | +Works with any server framework, any hosting provider, any deployment model. |
| 60 | + |
| 61 | +### 2. Direct (Fetcher) |
| 62 | + |
| 63 | +When you don't need streaming, the fetcher mode does exactly what it sounds like: |
| 64 | + |
| 65 | +```tsx |
| 66 | +const { generate, result, isLoading } = useGenerateImage({ |
| 67 | + fetcher: (input) => generateImageFn({ data: input }), |
| 68 | +}) |
| 69 | +``` |
| 70 | + |
| 71 | +The server function runs, returns JSON, the hook updates your UI. Simple, synchronous from the user's perspective, fully type-safe. |
| 72 | + |
| 73 | +### 3. Server Function Streaming |
| 74 | + |
| 75 | +This is the one we're most excited about. It combines the type safety of server functions with the real-time feedback of streaming, and it works beautifully with TanStack Start. |
| 76 | + |
| 77 | +The problem: the `connection` approach uses a generic `Record<string, any>` for its data payload. Your input loses all type information. The `fetcher` approach is fully typed, but blocks until the entire result is ready. |
| 78 | + |
| 79 | +Server Function Streaming gives you both. Your fetcher returns a `Response` (an SSE stream), and the client detects it automatically and parses the stream in real-time: |
| 80 | + |
| 81 | +```tsx |
| 82 | +// Client - identical API to a direct fetcher |
| 83 | +const { generate, result, isLoading } = useGenerateImage({ |
| 84 | + fetcher: (input) => generateImageStreamFn({ data: input }), |
| 85 | +}) |
| 86 | +``` |
| 87 | + |
| 88 | +```typescript |
| 89 | +// Server - add stream: true, wrap with toServerSentEventsResponse |
| 90 | +export const generateImageStreamFn = createServerFn({ method: 'POST' }) |
| 91 | + .inputValidator( |
| 92 | + z.object({ |
| 93 | + prompt: z.string(), |
| 94 | + numberOfImages: z.number().optional(), |
| 95 | + size: z.string().optional(), |
| 96 | + }), |
| 97 | + ) |
| 98 | + .handler(({ data }) => |
| 99 | + toServerSentEventsResponse( |
| 100 | + generateImage({ |
| 101 | + adapter: openaiImage('gpt-image-1'), |
| 102 | + prompt: data.prompt, |
| 103 | + stream: true, |
| 104 | + }), |
| 105 | + ), |
| 106 | + ) |
| 107 | +``` |
| 108 | + |
| 109 | +From the client's perspective the API is identical to a direct fetcher call. Behind the scenes, TanStack AI detects the `Response` object, reads the SSE stream, and feeds chunks through the same event pipeline as the connection adapter. Progress events fire in real-time. Errors are reported as they happen. Your `input` parameter stays fully typed throughout. |
| 110 | + |
| 111 | +Zero config. If your fetcher returns a `Response`, it's treated as an SSE stream. If it returns anything else, it's a direct result. |
| 112 | + |
| 113 | +## How It Works Under the Hood |
| 114 | + |
| 115 | +When a fetcher returns a `Response`, the `GenerationClient` runs a simple check: |
| 116 | + |
| 117 | +```typescript |
| 118 | +const result = await this.fetcher(input, { signal }) |
| 119 | + |
| 120 | +if (result instanceof Response) { |
| 121 | + // Parse as SSE stream — same pipeline as ConnectionAdapter |
| 122 | + await this.processStream(parseSSEResponse(result, signal)) |
| 123 | +} else { |
| 124 | + // Use as direct result |
| 125 | + this.setResult(result) |
| 126 | +} |
| 127 | +``` |
| 128 | + |
| 129 | +`parseSSEResponse` reads the response body as a stream of newline-delimited SSE events, parses each `data:` line into a `StreamChunk`, and yields them into the same `processStream` method that the ConnectionAdapter uses. Same event types, same state transitions, same callbacks. Every feature that works with streaming connections works with server function streaming: progress reporting, chunk callbacks, abort signals, error handling. |
| 130 | + |
| 131 | +## Result Transforms |
| 132 | + |
| 133 | +Sometimes the raw result from the server isn't what you want in state. Every generation hook accepts an `onResult` callback that transforms the result before it's stored: |
| 134 | + |
| 135 | +```tsx |
| 136 | +const { result } = useGenerateSpeech({ |
| 137 | + fetcher: (input) => generateSpeechStreamFn({ data: input }), |
| 138 | + onResult: (raw) => { |
| 139 | + const bytes = Uint8Array.from(atob(raw.audio), (c) => c.charCodeAt(0)) |
| 140 | + const blob = new Blob([bytes], { type: raw.contentType ?? 'audio/mpeg' }) |
| 141 | + return { |
| 142 | + audioUrl: URL.createObjectURL(blob), |
| 143 | + format: raw.format, |
| 144 | + duration: raw.duration, |
| 145 | + } |
| 146 | + }, |
| 147 | +}) |
| 148 | + |
| 149 | +// result is typed as { audioUrl: string; format?: string; duration?: number } | null |
| 150 | +``` |
| 151 | + |
| 152 | +TypeScript infers the output type from your transform. No explicit generics needed. |
| 153 | + |
| 154 | +## Video Generation |
| 155 | + |
| 156 | +Video generation is a different beast. Providers like OpenAI's Sora use a jobs-based architecture: submit a prompt, receive a job ID, poll for status until the video is ready. This can take minutes. |
| 157 | + |
| 158 | +`useGenerateVideo()` handles all of this transparently: |
| 159 | + |
| 160 | +```tsx |
| 161 | +const { generate, result, jobId, videoStatus, isLoading } = useGenerateVideo({ |
| 162 | + fetcher: (input) => generateVideoStreamFn({ data: input }), |
| 163 | +}) |
| 164 | + |
| 165 | +{ |
| 166 | + videoStatus && ( |
| 167 | + <div> |
| 168 | + <p>Status: {videoStatus.status}</p> |
| 169 | + {videoStatus.progress != null && ( |
| 170 | + <div |
| 171 | + className="progress-bar" |
| 172 | + style={{ width: `${videoStatus.progress}%` }} |
| 173 | + /> |
| 174 | + )} |
| 175 | + </div> |
| 176 | + ) |
| 177 | +} |
| 178 | +``` |
| 179 | + |
| 180 | +`jobId` and `videoStatus` are reactive state that update in real-time as the server streams polling updates. Your users see "pending", "processing", progress percentages, and finally the completed video URL. You write zero polling loops. |
| 181 | + |
| 182 | +## Every Activity, Same API |
| 183 | + |
| 184 | +| Hook | Input | Result | |
| 185 | +| --------------------- | ------------------------------------ | ----------------------------------------------- | |
| 186 | +| `useGenerateImage()` | `{ prompt, numberOfImages?, size? }` | `{ images: [{ url, b64Json, revisedPrompt }] }` | |
| 187 | +| `useGenerateSpeech()` | `{ text, voice?, format? }` | `{ audio, contentType, format, duration }` | |
| 188 | +| `useTranscription()` | `{ audio, language? }` | `{ text, segments, language, duration }` | |
| 189 | +| `useSummarize()` | `{ text, style?, maxLength? }` | `{ summary }` | |
| 190 | +| `useGenerateVideo()` | `{ prompt, size?, duration? }` | `{ jobId, status, url }` | |
| 191 | + |
| 192 | +Same `generate()`. Same `result`. Same `isLoading`. Same `error`. Same `stop()` and `reset()`. The consistency is intentional: AI features should be as easy to add to your app as a form submission. |
| 193 | + |
| 194 | +## Getting Started |
| 195 | + |
| 196 | +```bash |
| 197 | +pnpm add @tanstack/ai @tanstack/ai-react @tanstack/ai-client @tanstack/ai-openai |
| 198 | +``` |
| 199 | + |
| 200 | +Create a streaming server function: |
| 201 | + |
| 202 | +```typescript |
| 203 | +import { createServerFn } from '@tanstack/react-start' |
| 204 | +import { generateImage, toServerSentEventsResponse } from '@tanstack/ai' |
| 205 | +import { openaiImage } from '@tanstack/ai-openai' |
| 206 | + |
| 207 | +export const generateImageStreamFn = createServerFn({ method: 'POST' }) |
| 208 | + .inputValidator(z.object({ prompt: z.string() })) |
| 209 | + .handler(({ data }) => |
| 210 | + toServerSentEventsResponse( |
| 211 | + generateImage({ |
| 212 | + adapter: openaiImage('gpt-image-1'), |
| 213 | + prompt: data.prompt, |
| 214 | + stream: true, |
| 215 | + }), |
| 216 | + ), |
| 217 | + ) |
| 218 | +``` |
| 219 | + |
| 220 | +Use it in your component: |
| 221 | + |
| 222 | +```tsx |
| 223 | +import { useGenerateImage } from '@tanstack/ai-react' |
| 224 | +import { generateImageStreamFn } from '../lib/server-fns' |
| 225 | + |
| 226 | +function ImageGenerator() { |
| 227 | + const { generate, result, isLoading, error } = useGenerateImage({ |
| 228 | + fetcher: (input) => generateImageStreamFn({ data: input }), |
| 229 | + }) |
| 230 | + |
| 231 | + return ( |
| 232 | + <div> |
| 233 | + <button onClick={() => generate({ prompt: 'A mountain at dawn' })}> |
| 234 | + {isLoading ? 'Generating...' : 'Generate'} |
| 235 | + </button> |
| 236 | + {result?.images.map((img, i) => ( |
| 237 | + <img key={i} src={img.url} alt="Generated" /> |
| 238 | + ))} |
| 239 | + </div> |
| 240 | + ) |
| 241 | +} |
| 242 | +``` |
| 243 | + |
| 244 | +Three lines of hook setup. Type-safe input. Streaming progress. Error handling. Abort support. |
| 245 | + |
| 246 | +## What's Next |
| 247 | + |
| 248 | +Generation hooks are available now in `@tanstack/ai-client` and `@tanstack/ai-react`. Solid, Vue, and Svelte support is coming with the same API surface. |
| 249 | + |
| 250 | +We're also expanding the adapter ecosystem so you can use these hooks with providers beyond OpenAI. The generation functions are provider-agnostic by design, so swapping from OpenAI to Anthropic or a local model is a single line change. |
| 251 | + |
| 252 | +Build something and let us know what you make. |
0 commit comments