A streaming structured output emits the object as the model writes it. The UI can render each finished field before the object is complete.
Stream the JSON instead. useChat parses it as it arrives and gives you a typed partial object that fills in field by field.
This setup assumes chat already works in your app. For the schema file and the zod install, see the overview.
Pass outputSchema and stream: true:
// api/extract-person.ts
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { PersonSchema } from "../schemas/person";
export async function POST(request: Request) {
const { messages, threadId, runId } = await chatParamsFromRequest(request);
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages,
threadId,
runId,
outputSchema: PersonSchema,
stream: true,
});
return toServerSentEventsResponse(stream);
}import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
import { PersonSchema } from "./schemas/person";
export function PersonExtractor() {
const { sendMessage, isLoading, partial, final } = useChat({
connection: fetchServerSentEvents("/api/extract-person"),
threadId: "support-chat",
outputSchema: PersonSchema,
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
void sendMessage("Extract: John Doe, 30, john@example.com");
}}
>
<button disabled={isLoading}>Extract</button>
<p>Name: {partial.name ?? "..."}</p>
<p>Age: {partial.age ?? "..."}</p>
<p>Email: {partial.email ?? "..."}</p>
{final && <pre>{JSON.stringify(final, null, 2)}</pre>}
</form>
);
}Click Extract. The name, age, and email appear one after the other. When the run ends, the full object shows in the <pre> block.
For a full TanStack Start app that fills a table from partial.rows, open Streaming Structured Table.
Some adapters cannot stream the JSON. Anthropic and Ollama send the object as one event. Harness agents also send it at the end (see Harness Agents). In these cases partial stays {} until final is set. Your client code does not change.
If you leave out outputSchema, useChat returns its normal shape with no partial or final.
Vue, Solid, Svelte, Angular, Preact, and Octane take the same outputSchema option and return partial and final. Only the reactive type changes. See Quick Start for the code in each framework.
partial and final show only the object of the latest turn. Reasoning, tool calls, and the object of every turn are on messages[].parts:
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
import { z } from "zod";
// The same schema as schemas/person.ts. In your app, import it from that file.
const PersonSchema = z.object({
name: z.string().meta({ description: "The full name of the person" }),
age: z.number().meta({ description: "The age in years" }),
email: z.string().email(),
});
export function PersonRun() {
const { messages, sendMessage } = useChat({
connection: fetchServerSentEvents("/api/extract-person"),
threadId: "support-chat",
outputSchema: PersonSchema,
});
return (
<div>
<button onClick={() => void sendMessage("Extract: John Doe, 30")}>
Extract
</button>
{messages.map((message) => (
<div key={message.id}>
{message.parts.map((part, i) => {
if (part.type === "thinking") {
return <em key={i}>{part.content}</em>;
}
if (part.type === "tool-call") {
return (
<p key={part.id}>
{part.name} ({part.state})
</p>
);
}
if (part.type === "text") {
return <p key={i}>{part.content}</p>;
}
if (part.type === "structured-output") {
const person = part.data ?? part.partial;
return <p key={i}>Name: {person?.name ?? "..."}</p>;
}
return null;
})}
</div>
))}
</div>
);
}The structured-output part has these fields:
final equals data on the latest assistant turn.
| Chunk type | Part on messages[i].parts |
|---|---|
| REASONING_MESSAGE_CONTENT | ThinkingPart on the assistant message |
| TOOL_CALL_START / _ARGS / _END | ToolCallPart on the assistant message |
| TOOL_CALL_RESULT | ToolResultPart on the tool message |
| TEXT_MESSAGE_CONTENT, with outputSchema | StructuredOutputPart on the assistant message. The JSON deltas build part.raw, and the parse fills part.partial. |
| TEXT_MESSAGE_CONTENT, no outputSchema | TextPart on the assistant message |
chat({ outputSchema, stream: true }) returns a StructuredOutputStream<T>. It is an AsyncIterable of the normal stream events, plus two CUSTOM events:
{
type: "CUSTOM",
name: "structured-output.complete",
value: {
object: T; // full parsed object
raw: string; // full JSON text
reasoning?: string; // only for thinking or reasoning models
},
// standard event fields, such as timestamp
// the model id is in metadata.tanstack when present
}If the parse fails, the stream emits RUN_ERROR in place of RUN_FINISHED.
At runtime, value on the complete event also holds the same messageId. The client uses it to find the right message. This field is not on the public StructuredOutputCompleteEvent<T> type, but you can read it from value if you need it.
Streaming structured output works with every adapter. Only some send the JSON as a true stream in one request:
| Adapter | Behavior with outputSchema and stream: true |
|---|---|
| @tanstack/ai-openai | Native stream in one request (Responses API, text.format: json_schema) |
| @tanstack/ai-openrouter | Native stream in one request (response_format: json_schema) |
| @tanstack/ai-grok | Native stream in one request (Chat Completions, response_format: json_schema) |
| @tanstack/ai-groq | Native stream in one request (Chat Completions, response_format: json_schema) |
| @tanstack/ai-bedrock | Native stream through Converse or an OpenAI-compatible API |
| @tanstack/ai-byteplus | Native stream in one request on supported models. Other models emit RUN_ERROR. |
| @tanstack/ai-llmgateway | Native stream in one request (Chat Completions, response_format: json_schema) |
| @tanstack/ai-cloudflare | Native stream in one request (Chat Completions, response_format: json_schema) |
| @tanstack/ai-neon | Native stream in one request (Chat Completions, response_format: json_schema). Claude uses the Anthropic adapter. |
| @tanstack/ai-lovable | Native stream in one request (Responses or Chat Completions) |
| @tanstack/ai-gemini | Native stream in one request (generateContentStream, responseSchema) |
| Other adapters (Anthropic, Ollama, and more) | Runs structuredOutput without streaming, then emits the object as one structured-output.complete event |
Your client code is the same on all adapters. You see deltas only when the adapter implements structuredOutputStream.
With no HTTP boundary (a Node script, a CLI, a test), iterate the stream directly:
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { PersonSchema } from "./schemas/person";
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages: [{ role: "user", content: "Extract: John Doe is 30, john@example.com" }],
threadId: "thread-1",
outputSchema: PersonSchema,
stream: true,
});
for await (const chunk of stream) {
if (chunk.type === "CUSTOM" && chunk.name === "structured-output.complete") {
// Typed from PersonSchema. Validate here if you need to.
console.log(chunk.value.object.name, chunk.value.object.age);
}
}This is the same StructuredOutputStream<T> that the server endpoint gives to toServerSentEventsResponse.
useChat also takes onChunk. The hook updates partial and final first. Then it calls your onChunk with the same chunk. Use it for a custom progress UI.
When you set outputSchema, stream: true, and tools, the agent loop runs first. The structured stream emits its complete event only after every tool finishes. Tool approvals and client tools work the same as in a normal chat. See With Tools.