Skip to content
Media

Generation Hooks

A generation hook tracks the request, the loading state, the error, and the result for one media call.

Quick setup

This connects a React component to an image route. It takes about 5 minutes.

  1. Install the packages:
shell
npm i @tanstack/ai @tanstack/ai-react @tanstack/ai-openai
  1. Add a POST route at /api/generate/image. It reads OPENAI_API_KEY from the environment:
ts
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,
    size: '1024x1024',
    stream: true,
  })
  return toServerSentEventsResponse(stream)
}
  1. Add the hook to a component:
tsx
import { useState } from 'react'
import { fetchServerSentEvents, useGenerateImage } from '@tanstack/ai-react'

export function ImageGenerator() {
  const [prompt, setPrompt] = useState('')
  const { generate, result, isLoading, error, reset } = useGenerateImage({
    connection: fetchServerSentEvents('/api/generate/image'),
    threadId: 'image-studio',
  })

  return (
    <div>
      <input
        value={prompt}
        onChange={(e) => setPrompt(e.target.value)}
        placeholder="Describe an image..."
      />
      <button
        onClick={() => generate({ prompt })}
        disabled={isLoading || !prompt.trim()}
      >
        {isLoading ? 'Generating...' : 'Generate'}
      </button>

      {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={img.revisedPrompt || 'Generated image'}
        />
      ))}

      {result && <button onClick={reset}>Clear</button>}
    </div>
  )
}

Type a prompt and click Generate. The button shows the loading state, then the image appears. Clear resets the hook.

How it works

Every generation hook does the same job:

  1. generate(input) sends the input to your server.
  2. While the server works, isLoading is true and status is 'generating'.
  3. When the result arrives, the hook stores it on result. If the server fails, the hook stores the failure on error.

You give the hook one transport:

  • connection: a streaming transport, such as fetchServerSentEvents('/api/...'). Use it with an API route.
  • fetcher: an async function, such as a TanStack Start server function. It can return the result, or an SSE Response.

The server half and the three transports are explained in Generations.

HookInputResult
useGenerateImageImageGenerateInputImageGenerationResult
useGenerateAudioAudioGenerateInputAudioGenerationResult
useGenerateSpeechSpeechGenerateInputTTSResult
useTranscriptionTranscriptionGenerateInputTranscriptionResult
useSummarizeSummarizeGenerateInputSummarizationResult
useGenerateVideoVideoGenerateInputVideoGenerateResult
useGenerationYour own TInputYour own TResult

Every hook returns generate, result, isLoading, error, status, stop, reset, and runId. The full tables are in Options and Return value.

Hooks by media type

Each section lists what generate() accepts. The linked guide shows the server route and the provider options.

useGenerateImage

Makes one or more images. Guide: Image Generation.

FieldTypeDescription
promptstring | MediaPromptPart[]What to draw (required). An array of text and image parts works on models that take reference images.
numberOfImagesnumberHow many images to make
sizestringImage size, for example "1024x1024"
modelOptionsRecord<string, any>Model-specific options

useGenerateAudio

Makes music or sound effects from a prompt. Guide: Audio Generation.

FieldTypeDescription
promptstringWhat the audio sounds like (required)
durationnumberLength in seconds
modelOptionsRecord<string, any>Model-specific options

This example also shows a fetcher with Bring Your Own Key (BYOK). The user's key goes in x-byok-* headers. With a fetcher, spread headers from the second argument onto your request so the keys reach the server:

tsx
import { useGenerateAudio } from '@tanstack/ai-react'
import { defineByok, defaultByokStorage } from '@tanstack/ai-client/byok'

const byok = defineByok({ storage: defaultByokStorage() })
const threadId = 'audio'

export function AudioGenerator() {
  const { generate, result, isLoading, error } = useGenerateAudio({
    byok,
    byokProvider: () => 'fal',
    fetcher: (input, { threadId, signal, headers }) =>
      fetch('/api/generate/audio', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', ...headers },
        body: JSON.stringify({ ...input, threadId }),
        signal,
      }),
    threadId,
  })

  return (
    <div>
      <button
        onClick={() => generate({ prompt: 'An upbeat electronic track' })}
        disabled={isLoading}
      >
        {isLoading ? 'Generating...' : 'Generate'}
      </button>
      {error && <p role="alert">Error: {error.message}</p>}
      {result?.audio.url && <audio src={result.audio.url} controls />}
    </div>
  )
}

On the server, read the key from the header, then run generateAudio:

ts
import { generateAudio, toServerSentEventsResponse } from '@tanstack/ai'
import { falAudio } from '@tanstack/ai-fal'
import { falByok } from '@tanstack/ai-fal/byok'
import { byokMissing, getByokKey } from '@tanstack/ai/byok/server'

export async function POST(request: Request) {
  const apiKey = getByokKey(request, falByok)
  if (!apiKey) return byokMissing(falByok)

  const body: unknown = await request.json()
  if (
    typeof body !== 'object' ||
    body === null ||
    !('prompt' in body) ||
    !('threadId' in body)
  ) {
    return new Response('Bad request', { status: 400 })
  }
  const { prompt, threadId } = body
  if (typeof prompt !== 'string' || typeof threadId !== 'string') {
    return new Response('Bad request', { status: 400 })
  }

  const stream = generateAudio({
    adapter: falAudio('fal-ai/minimax-music/v2.6', { apiKey }),
    prompt,
    threadId,
    stream: true,
  })
  return toServerSentEventsResponse(stream)
}

The fetcher posts the input and the threadId, so this route reads prompt and threadId from the top of the body. For defineByok and a UI to save keys, see Bring Your Own Key.

useGenerateSpeech

Turns text into spoken audio. Guide: Text-to-Speech.

FieldTypeDescription
textstringThe text to speak. Leave it out when you send turns.
turnsArray<TTSTurn>Dialogue turns with more than one voice. Only for adapters that support more than one speaker.
timestampsbooleanAsk for alignment and segments on the result. Only for adapters that support timestamps.
voicestringThe voice, for example "alloy"
format'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm'Output audio format
speednumberSpeed, from 0.25 to 4.0
modelOptionsRecord<string, any>Model-specific options

TTSResult holds audio (base64) and format. It can also hold duration, contentType, alignment, and segments. To play it:

tsx
import { fetchServerSentEvents, useGenerateSpeech } from '@tanstack/ai-react'

export function SpeechPlayer() {
  const { generate, result, isLoading } = useGenerateSpeech({
    connection: fetchServerSentEvents('/api/generate/speech'),
    threadId: 'speech',
  })

  return (
    <div>
      <button
        onClick={() => generate({ text: 'Hello from TanStack AI!', voice: 'alloy' })}
        disabled={isLoading}
      >
        Speak
      </button>
      {result && (
        <audio
          src={`data:audio/${result.format};base64,${result.audio}`}
          controls
          autoPlay
        />
      )}
    </div>
  )
}

useTranscription

Turns recorded audio into text. Guide: Transcription.

FieldTypeDescription
audiostring | File | Blob | ArrayBufferThe audio (required). Over a connection, send a base64 string or a data URL.
languagestringLanguage in ISO-639-1 format, for example "en"
promptstringA hint that guides the transcription
responseFormat'json' | 'text' | 'srt' | 'verbose_json' | 'vtt'Output format
modelOptionsRecord<string, any>Model-specific options

TranscriptionResult holds text. It can also hold language, duration, segments, and words.

useSummarize

Makes a summary of long text. There is no separate guide, so the full example is here.

tsx
import { useState } from 'react'
import { fetchServerSentEvents, useSummarize } from '@tanstack/ai-react'

export function Summarizer() {
  const [text, setText] = useState('')
  const { generate, result, isLoading, error } = useSummarize({
    connection: fetchServerSentEvents('/api/summarize'),
    threadId: 'summary',
  })

  return (
    <div>
      <textarea
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="Paste text to summarize..."
        rows={8}
      />
      <button
        onClick={() => generate({ text, style: 'bullet-points', maxLength: 200 })}
        disabled={isLoading || !text.trim()}
      >
        {isLoading ? 'Summarizing...' : 'Summarize'}
      </button>
      {error && <p role="alert">Error: {error.message}</p>}
      {result && <p>{result.summary}</p>}
    </div>
  )
}
FieldTypeDescription
textstringThe text to summarize (required)
maxLengthnumberMaximum length of the summary
style'bullet-points' | 'paragraph' | 'concise'Summary style
focusArray<string>Topics to focus on
modelOptionsRecord<string, any>Model-specific options

On the server, call summarize() with stream: true and return toServerSentEventsResponse(). generationParamsFromRequest has no summarize kind, so read the envelope yourself. The hook sends its input in forwardedProps, and threadId and runId at the top of the body:

ts
import { summarize, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiSummarize } from '@tanstack/ai-openai'

export async function POST(request: Request) {
  const body: unknown = await request.json()
  if (
    typeof body !== 'object' ||
    body === null ||
    !('threadId' in body) ||
    !('forwardedProps' in body)
  ) {
    return new Response('Bad request', { status: 400 })
  }
  const { threadId, forwardedProps } = body
  if (
    typeof threadId !== 'string' ||
    typeof forwardedProps !== 'object' ||
    forwardedProps === null ||
    !('text' in forwardedProps) ||
    typeof forwardedProps.text !== 'string'
  ) {
    return new Response('Bad request', { status: 400 })
  }
  const runId =
    'runId' in body && typeof body.runId === 'string' ? body.runId : undefined

  const stream = summarize({
    adapter: openaiSummarize('gpt-5.5'),
    text: forwardedProps.text,
    style: 'bullet-points',
    maxLength: 200,
    threadId,
    runId,
    stream: true,
  })
  return toServerSentEventsResponse(stream)
}

useGenerateVideo

Makes a video. A video runs as a job: the server starts it, then polls the provider until it is done. Guide: Video Generation.

FieldTypeDescription
promptstring | MediaPromptPart[]What the video shows (required). An array of text and image parts works on models that take a start frame or references.
sizestringVideo size. The format depends on the provider, for example "16:9" or "1280x720".
durationnumberLength in seconds
modelOptionsRecord<string, any>Model-specific options

useGenerateVideo returns two more values, so you can show progress:

PropertyTypeDescription
jobIdstring | nullThe job id. Set when the server creates the job.
videoStatusVideoStatusInfo | nullThe last status update: status, progress, and jobId
ts
interface VideoStatusInfo {
  jobId: string
  status: 'pending' | 'processing' | 'completed' | 'failed'
  progress?: number // 0-100
  url?: string // set when completed
  error?: string // set when failed
}

It also takes two more callbacks: onJobCreated(jobId) and onStatusUpdate(status).

Framework variants

Every framework has the same hooks with the same options. Only the names and the reactive values change.

GenerationReact, Vue, Solid, OctaneSvelte, RemixAngular
ImageuseGenerateImagecreateGenerateImageinjectGenerateImage
AudiouseGenerateAudiocreateGenerateAudioinjectGenerateAudio
SpeechuseGenerateSpeechcreateGenerateSpeechinjectGenerateSpeech
TranscriptionuseTranscriptioncreateTranscriptioninjectTranscription
SummarizationuseSummarizecreateSummarizeinjectSummarize
VideouseGenerateVideocreateGenerateVideoinjectGenerateVideo
Base (custom)useGenerationcreateGenerationinjectGeneration

How to read the values in each framework:

  • Vue (@tanstack/ai-vue): values are DeepReadonly<ShallowRef<>>. Read them with .value in <script> and in <template>.
  • Solid (@tanstack/ai-solid): values are accessors. Call them, for example result().
  • Svelte (@tanstack/ai-svelte): values are Svelte 5 $state.
  • Angular (@tanstack/ai-angular): values are read-only signals. Call them, for example result().
  • Remix (@tanstack/ai-remix): pass the component handle as the first argument, for example createGenerateImage(handle, options).

Every package re-exports fetchServerSentEvents and fetchHttpStream from @tanstack/ai-client. For full samples, see the API pages for Vue, Solid, Svelte, Angular, Octane, and Remix.

Advanced

Options

Every hook takes these options:

OptionTypeDescription
connectionConnectConnectionAdapterStreaming transport (SSE, HTTP stream, or custom)
fetcher(input, options) => Promise<Result | Response>An async function, or a server function that returns an SSE Response. options holds signal, threadId, runId, and headers.
bodyRecord<string, any>Extra fields sent with each connection request
threadIdstringRequired. A stable name for this generation slot.
persistencebooleantrue restores the last run for threadId from the server when the component mounts
onResult(result) => TOutput | null | voidReact to the result, or change what is stored. See Result transforms.
onError(error: Error) => voidCalled when the run fails
onProgress(progress: number, message?: string) => voidProgress updates, from 0 to 100

Options for special cases:

OptionTypeDescription
byokByokClientA keyring. Keys go in x-byok-* headers, never in the body.
byokProvider() => ProviderId | undefinedThe provider slug. If it returns a slug, only that key is sent. Otherwise the hook uses body.provider. If no slug resolves, generate throws.
onChunk(chunk: StreamChunk) => voidCalled for each stream event. Only with connection.
hydrateGenerationhandlerRestores the last run when the connection has no restore handler, for example next to a fetcher
joinRunhandlerRe-attaches to a run that is still going after a reload. Without it, a restored running run shows as an error.
devtoolsAIDevtoolsDisplayOptionsHow the run shows in TanStack AI Devtools

Return value

PropertyTypeDescription
generate(input) => Promise<void>Start a generation
resultTOutput | nullThe result, after any transform. null before the first result.
isLoadingbooleantrue while a generation runs
errorError | undefinedThe current error, if any
statusGenerationClientState'idle', 'generating', 'success', or 'error'
stop() => voidAbort the current generation
reset() => voidClear the result and the error, and go back to 'idle'
runIdstring | nullThe id of the run in progress, or null

stop() only aborts the local stream. Work that already runs on the provider continues. To cancel or poll that provider job, send runId to your own endpoint.

Result transforms

onResult can change what the hook stores on result:

  • Return a value that is not null: the hook stores that value.
  • Return null: the hook keeps the previous result. Use this to filter results.
  • Return nothing: the hook stores the raw result.

TypeScript infers the type of result from what onResult returns. You do not need a generic parameter.

tsx
import { fetchServerSentEvents, useGenerateSpeech } from '@tanstack/ai-react'
import type { TTSResult } from '@tanstack/ai'

export function SpeechPlayer() {
  const { result } = useGenerateSpeech({
    connection: fetchServerSentEvents('/api/generate/speech'),
    threadId: 'speech',
    onResult: (raw: TTSResult) => ({
      audioUrl: `data:${raw.contentType};base64,${raw.audio}`,
      duration: raw.duration,
    }),
  })
  // result is { audioUrl: string; duration?: number } | null
}

Keep results after a reload

Every generation hook takes the same persistence option that useChat takes. With it, the status and the result of a long run come back after a page reload or a dropped connection.

The hook restores by threadId. For a generation, threadId names the slot that successive runs fill, for example product-7-hero. It does not name a conversation. See Id map. The setup is in Generation Persistence.

Custom generations with useGeneration

All the hooks above use useGeneration internally. Use it directly for a generation type that has no built-in hook:

tsx
import { fetchServerSentEvents, useGeneration } from '@tanstack/ai-react'

interface EmbeddingInput {
  text: string
  model?: string
}

interface EmbeddingResult {
  embedding: Array<number>
  model: string
  usage: { totalTokens: number }
}

export function EmbeddingGenerator() {
  const { generate, result, isLoading } = useGeneration<
    EmbeddingInput,
    EmbeddingResult
  >({
    connection: fetchServerSentEvents('/api/generate/embedding'),
    threadId: 'generation',
  })

  return (
    <div>
      <button onClick={() => generate({ text: 'Hello world' })} disabled={isLoading}>
        Generate embedding
      </button>
      {result && <p>Dimensions: {result.embedding.length}</p>}
    </div>
  )
}

useGeneration takes the same options and returns the same values as the other hooks.