Structured outputs allow you to constrain AI model responses to match a specific JSON schema, ensuring consistent and type-safe data extraction. TanStack AI uses the Standard JSON Schema specification, allowing you to use any compatible schema library.
When you provide an outputSchema to the chat() function, TanStack AI:
This is useful for:
TanStack AI uses Standard JSON Schema, which means you can use any schema library that implements the specification:
Note: Refer to your schema library's documentation for details on defining schemas and using features like .meta() for field descriptions. TanStack AI will convert your schema to JSON Schema format automatically.
Here's how to use structured outputs with a Zod schema:
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { z } from "zod";
// Define your schema
const PersonSchema = z.object({
name: z.string().meta({ description: "The person's full name" }),
age: z.number().meta({ description: "The person's age in years" }),
email: z.string().email().meta({ description: "The person's email address" }),
});
// Use it with chat()
const person = await chat({
adapter: openaiText("gpt-5.2"),
messages: [
{
role: "user",
content: "Extract the person info: John Doe is 30 years old, email john@example.com",
},
],
outputSchema: PersonSchema,
});
// person is fully typed as { name: string, age: number, email: string }
console.log(person.name); // "John Doe"
console.log(person.age); // 30
console.log(person.email); // "john@example.com"import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { z } from "zod";
// Define your schema
const PersonSchema = z.object({
name: z.string().meta({ description: "The person's full name" }),
age: z.number().meta({ description: "The person's age in years" }),
email: z.string().email().meta({ description: "The person's email address" }),
});
// Use it with chat()
const person = await chat({
adapter: openaiText("gpt-5.2"),
messages: [
{
role: "user",
content: "Extract the person info: John Doe is 30 years old, email john@example.com",
},
],
outputSchema: PersonSchema,
});
// person is fully typed as { name: string, age: number, email: string }
console.log(person.name); // "John Doe"
console.log(person.age); // 30
console.log(person.email); // "john@example.com"The return type of chat() changes based on the outputSchema prop:
| Configuration | Return Type |
|---|---|
| No outputSchema | AsyncIterable<StreamChunk> |
| With outputSchema | Promise<InferSchemaType<TSchema>> |
| With outputSchema and stream: true | StructuredOutputStream<InferSchemaType<TSchema>> |
When you provide an outputSchema, TanStack AI automatically infers the TypeScript type from your schema:
import { z } from "zod";
// Define a complex schema
const RecipeSchema = z.object({
name: z.string(),
prepTime: z.string(),
servings: z.number(),
ingredients: z.array(
z.object({
item: z.string(),
amount: z.string(),
})
),
instructions: z.array(z.string()),
});
// TypeScript knows the exact return type
const recipe = await chat({
adapter: openaiText("gpt-5.2"),
messages: [{ role: "user", content: "Give me a recipe for scrambled eggs" }],
outputSchema: RecipeSchema,
});
// Full type safety - TypeScript knows all these properties exist
recipe.name; // string
recipe.prepTime; // string
recipe.servings; // number
recipe.ingredients[0].item; // string
recipe.instructions.map((step) => step.toUpperCase()); // string[]import { z } from "zod";
// Define a complex schema
const RecipeSchema = z.object({
name: z.string(),
prepTime: z.string(),
servings: z.number(),
ingredients: z.array(
z.object({
item: z.string(),
amount: z.string(),
})
),
instructions: z.array(z.string()),
});
// TypeScript knows the exact return type
const recipe = await chat({
adapter: openaiText("gpt-5.2"),
messages: [{ role: "user", content: "Give me a recipe for scrambled eggs" }],
outputSchema: RecipeSchema,
});
// Full type safety - TypeScript knows all these properties exist
recipe.name; // string
recipe.prepTime; // string
recipe.servings; // number
recipe.ingredients[0].item; // string
recipe.instructions.map((step) => step.toUpperCase()); // string[]Schema field descriptions help the AI understand what data to extract. In Zod 4+, use the .meta() method:
const ProductSchema = z.object({
name: z.string().meta({ description: "The product name" }),
price: z.number().meta({ description: "Price in USD" }),
inStock: z.boolean().meta({ description: "Whether the product is currently available" }),
categories: z
.array(z.string())
.meta({ description: "Product categories like 'electronics', 'clothing', etc." }),
});const ProductSchema = z.object({
name: z.string().meta({ description: "The product name" }),
price: z.number().meta({ description: "Price in USD" }),
inStock: z.boolean().meta({ description: "Whether the product is currently available" }),
categories: z
.array(z.string())
.meta({ description: "Product categories like 'electronics', 'clothing', etc." }),
});These descriptions are included in the JSON Schema sent to the provider, giving the AI context about each field.
You can define deeply nested structures:
const CompanySchema = z.object({
name: z.string(),
founded: z.number().meta({ description: "Year the company was founded" }),
headquarters: z.object({
city: z.string(),
country: z.string(),
address: z.string().optional(),
}),
employees: z.array(
z.object({
name: z.string(),
role: z.string(),
department: z.string(),
})
),
financials: z
.object({
revenue: z.number().meta({ description: "Annual revenue in millions USD" }),
profitable: z.boolean(),
})
.optional(),
});
const company = await chat({
adapter: anthropicText("claude-sonnet-4-5"),
messages: [
{
role: "user",
content: "Extract company info from this article: ...",
},
],
outputSchema: CompanySchema,
});
// Access nested properties with full type safety
console.log(company.headquarters.city);
console.log(company.employees[0].role);const CompanySchema = z.object({
name: z.string(),
founded: z.number().meta({ description: "Year the company was founded" }),
headquarters: z.object({
city: z.string(),
country: z.string(),
address: z.string().optional(),
}),
employees: z.array(
z.object({
name: z.string(),
role: z.string(),
department: z.string(),
})
),
financials: z
.object({
revenue: z.number().meta({ description: "Annual revenue in millions USD" }),
profitable: z.boolean(),
})
.optional(),
});
const company = await chat({
adapter: anthropicText("claude-sonnet-4-5"),
messages: [
{
role: "user",
content: "Extract company info from this article: ...",
},
],
outputSchema: CompanySchema,
});
// Access nested properties with full type safety
console.log(company.headquarters.city);
console.log(company.employees[0].role);Pass stream: true alongside outputSchema to receive incremental JSON deltas while the model is generating, plus a final validated, typed object. This is the path to take when you want a progressive UI — a streaming form, a typewriter-style preview, partial cards filling in field by field — instead of a single blocking await.
You build it in two halves: a server route that runs chat({ outputSchema, stream: true }) and pipes the result as Server-Sent Events, and a client that wires useChat to that endpoint and updates state as chunks arrive. The same flow as regular streaming chat (see Streaming) — outputSchema + stream: true just adds one terminal event with the validated object.
// app/api/extract-person/route.ts (or your framework's equivalent)
import { chat, toServerSentEventsResponse } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { z } from "zod";
const PersonSchema = z.object({
name: z.string().meta({ description: "The person's full name" }),
age: z.number().meta({ description: "The person's age in years" }),
email: z.string().email(),
});
export async function POST(request: Request) {
const { messages } = await request.json();
const stream = chat({
adapter: openaiText("gpt-5.2"),
messages,
outputSchema: PersonSchema,
stream: true,
});
return toServerSentEventsResponse(stream);
}// app/api/extract-person/route.ts (or your framework's equivalent)
import { chat, toServerSentEventsResponse } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { z } from "zod";
const PersonSchema = z.object({
name: z.string().meta({ description: "The person's full name" }),
age: z.number().meta({ description: "The person's age in years" }),
email: z.string().email(),
});
export async function POST(request: Request) {
const { messages } = await request.json();
const stream = chat({
adapter: openaiText("gpt-5.2"),
messages,
outputSchema: PersonSchema,
stream: true,
});
return toServerSentEventsResponse(stream);
}That's the entire server side. chat({ outputSchema, stream: true }) returns a StructuredOutputStream<InferSchemaType<typeof PersonSchema>> — the same kind of AsyncIterable that toServerSentEventsResponse accepts for any streaming chat endpoint. The schema travels in the request as JSON Schema, validation runs server-side after the stream completes, and the validated object is emitted as the terminal structured-output.complete event.
Pass the same schema to useChat and the hook tracks the progressive object and the validated terminal object for you — partial updates as JSON streams in, final snaps when structured-output.complete arrives. No external state, no onChunk ceremony, no parsePartialJSON calls:
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
import { z } from "zod";
const PersonSchema = z.object({
name: z.string(),
age: z.number(),
email: z.string().email(),
});
function PersonExtractor() {
const { sendMessage, isLoading, partial, final } = useChat({
connection: fetchServerSentEvents("/api/extract-person"),
outputSchema: PersonSchema,
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
sendMessage("Extract: John Doe, 30, john@example.com");
}}
>
<button disabled={isLoading}>Extract</button>
{/* `partial` fills in field by field as JSON streams in. */}
<p>Name: {partial.name ?? "…"}</p>
<p>Age: {partial.age ?? "…"}</p>
<p>Email: {partial.email ?? "…"}</p>
{final && <pre>Validated: {JSON.stringify(final, null, 2)}</pre>}
</form>
);
}import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
import { z } from "zod";
const PersonSchema = z.object({
name: z.string(),
age: z.number(),
email: z.string().email(),
});
function PersonExtractor() {
const { sendMessage, isLoading, partial, final } = useChat({
connection: fetchServerSentEvents("/api/extract-person"),
outputSchema: PersonSchema,
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
sendMessage("Extract: John Doe, 30, john@example.com");
}}
>
<button disabled={isLoading}>Extract</button>
{/* `partial` fills in field by field as JSON streams in. */}
<p>Name: {partial.name ?? "…"}</p>
<p>Age: {partial.age ?? "…"}</p>
<p>Email: {partial.email ?? "…"}</p>
{final && <pre>Validated: {JSON.stringify(final, null, 2)}</pre>}
</form>
);
}What the hook does for you:
The outputSchema field is optional: if you omit it, useChat's return type is unchanged, and partial / final aren't present.
Reasoning tokens and tool calls aren't on partial / final — they're already where they'd be in a normal chat: on messages[…].parts. The stream processor inside useChat routes each chunk type to its canonical part:
| Chunk type | Where it lands |
|---|---|
| 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 | TextPart on the assistant message (this is the raw JSON when outputSchema is set — see below) |
So render reasoning and tool calls the same way you'd render them in a normal chat UI:
const last = messages.at(-1);
return (
<>
{last?.parts.map((part, i) => {
if (part.type === "thinking") return <ReasoningView key={i} text={part.text} />;
if (part.type === "tool-call") return <ToolCallView key={i} part={part} />;
// Hide raw JSON text — the structured view below replaces it.
if (part.type === "text") return null;
return null;
})}
<StructuredView data={final ?? partial} />
</>
);const last = messages.at(-1);
return (
<>
{last?.parts.map((part, i) => {
if (part.type === "thinking") return <ReasoningView key={i} text={part.text} />;
if (part.type === "tool-call") return <ToolCallView key={i} part={part} />;
// Hide raw JSON text — the structured view below replaces it.
if (part.type === "text") return null;
return null;
})}
<StructuredView data={final ?? partial} />
</>
);Note: When outputSchema is set, the assistant's TextPart contains the raw JSON the model produced (e.g. {"name":"John","age":30,…}). That's not meant to be shown to end users — the structured view powered by partial / final replaces it. Filter text parts out of your message renderer in this mode, as in the snippet above.
Going lower-level? useChat still exposes onChunk if you want to observe individual chunks alongside the managed partial / final state (e.g. to drive a custom progress UI). The two paths compose — internal partial/final tracking always runs first, then your onChunk callback fires with the same chunk.
useChat (React, Vue, Solid) and createChat (Svelte) all accept the same outputSchema option and expose partial / final with the same semantics — only the reactivity primitive differs (React state, Vue shallowRef, Solid Accessor, Svelte reactive getter). See your framework's quick-start for the local idioms.
chat({ outputSchema, stream: true }) returns a StructuredOutputStream<T> — an AsyncIterable over the standard StreamChunk lifecycle plus a terminal CUSTOM event named structured-output.complete:
{
type: "CUSTOM",
name: "structured-output.complete",
value: {
object: T; // validated, parsed, typed
raw: string; // full accumulated JSON text
reasoning?: string; // present only for thinking/reasoning models
},
// ...standard event fields (timestamp, model, …)
}{
type: "CUSTOM",
name: "structured-output.complete",
value: {
object: T; // validated, parsed, typed
raw: string; // full accumulated JSON text
reasoning?: string; // present only for thinking/reasoning models
},
// ...standard event fields (timestamp, model, …)
}Streaming structured output works with every adapter, but only some support a true single-request streaming wire format:
| Adapter | Behavior with outputSchema + stream: true |
|---|---|
| @tanstack/ai-openai | Native single-request stream (Responses API, text.format: json_schema) |
| @tanstack/ai-openrouter | Native single-request stream (response_format: json_schema) |
| @tanstack/ai-grok | Native single-request stream (Chat Completions, response_format: json_schema) |
| @tanstack/ai-groq | Native single-request stream (Chat Completions, response_format: json_schema) |
| Other adapters (anthropic, gemini, ollama, …) | Fallback: runs non-streaming structuredOutput and emits the final object as one structured-output.complete event |
The fallback path keeps the consumer code identical across providers — you always read the final object off structured-output.complete — but you won't see incremental deltas unless the adapter implements structuredOutputStream natively.
When you don't need the SSE-over-HTTP boundary — Node scripts, CLIs, server endpoints that respond with a final JSON object instead of a stream, or tests — you can consume chat({ outputSchema, stream: true }) as a plain async iterable:
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { z } from "zod";
const PersonSchema = z.object({ name: z.string(), age: z.number(), email: z.string().email() });
const stream = chat({
adapter: openaiText("gpt-5.2"),
messages: [{ role: "user", content: "Extract: John Doe is 30, john@example.com" }],
outputSchema: PersonSchema,
stream: true,
});
for await (const chunk of stream) {
if (chunk.type === "CUSTOM" && chunk.name === "structured-output.complete") {
// Validated and typed against PersonSchema.
console.log(chunk.value.object.name);
console.log(chunk.value.object.age);
}
}import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { z } from "zod";
const PersonSchema = z.object({ name: z.string(), age: z.number(), email: z.string().email() });
const stream = chat({
adapter: openaiText("gpt-5.2"),
messages: [{ role: "user", content: "Extract: John Doe is 30, john@example.com" }],
outputSchema: PersonSchema,
stream: true,
});
for await (const chunk of stream) {
if (chunk.type === "CUSTOM" && chunk.name === "structured-output.complete") {
// Validated and typed against PersonSchema.
console.log(chunk.value.object.name);
console.log(chunk.value.object.age);
}
}This is the same StructuredOutputStream<T> the server endpoint above hands to toServerSentEventsResponse. Pick this shape when you're a single process end-to-end; use the server-endpoint-plus-useChat shape when there's a network in the middle.
Structured outputs work seamlessly with the agentic tool loop. When both outputSchema and tools are provided, TanStack AI will:
import { chat, toolDefinition } from "@tanstack/ai";
import { z } from "zod";
const getProductPrice = toolDefinition({
name: "get_product_price",
description: "Get the current price of a product",
inputSchema: z.object({
productId: z.string(),
}),
}).server(async ({ input }) => {
// Fetch price from database
return { price: 29.99, currency: "USD" };
});
const RecommendationSchema = z.object({
productName: z.string(),
currentPrice: z.number(),
reason: z.string(),
});
const recommendation = await chat({
adapter: openaiText("gpt-5.2"),
messages: [
{
role: "user",
content: "Recommend a product for a developer",
},
],
tools: [getProductPrice],
outputSchema: RecommendationSchema,
});
// The AI will call the tool to get prices, then return structured output
console.log(recommendation.productName);
console.log(recommendation.currentPrice);
console.log(recommendation.reason);import { chat, toolDefinition } from "@tanstack/ai";
import { z } from "zod";
const getProductPrice = toolDefinition({
name: "get_product_price",
description: "Get the current price of a product",
inputSchema: z.object({
productId: z.string(),
}),
}).server(async ({ input }) => {
// Fetch price from database
return { price: 29.99, currency: "USD" };
});
const RecommendationSchema = z.object({
productName: z.string(),
currentPrice: z.number(),
reason: z.string(),
});
const recommendation = await chat({
adapter: openaiText("gpt-5.2"),
messages: [
{
role: "user",
content: "Recommend a product for a developer",
},
],
tools: [getProductPrice],
outputSchema: RecommendationSchema,
});
// The AI will call the tool to get prices, then return structured output
console.log(recommendation.productName);
console.log(recommendation.currentPrice);
console.log(recommendation.reason);When you combine tools + outputSchema + stream: true, the agent loop runs first — its events stream through, and only after all tools complete does the structured output stream emit structured-output.complete. Two situations can interrupt that flow before the terminal event arrives:
There's nothing structured-output-specific in either flow — both reuse the standard chat pause/resume APIs. The structured stream layers on top: once tools complete (or the user approves), the agent loop finishes, the structured-output stream takes over, partial fills in, and final snaps when structured-output.complete arrives. For example, an approval-gated tool inside a structured-output run looks like:
const { messages, sendMessage, partial, final, addToolApprovalResponse } = useChat({
connection: fetchServerSentEvents("/api/recommend"),
outputSchema: RecommendationSchema,
tools: [sendEmail], // server tool with needsApproval: true
});
const last = messages.at(-1);
return (
<>
{last?.parts.map((part, i) => {
// Surface approval prompts inline, the same way Tool Approval Flow shows it.
if (
part.type === "tool-call" &&
part.state === "approval-requested" &&
part.approval
) {
return (
<ApprovalPrompt
key={i}
part={part}
onApprove={() =>
addToolApprovalResponse({ id: part.approval!.id, approved: true })
}
onDeny={() =>
addToolApprovalResponse({ id: part.approval!.id, approved: false })
}
/>
);
}
if (part.type === "thinking") return <ReasoningView key={i} text={part.text} />;
if (part.type === "tool-call") return <ToolCallView key={i} part={part} />;
return null; // hide TextPart (raw JSON when outputSchema is set)
})}
<StructuredView data={final ?? partial} />
</>
);const { messages, sendMessage, partial, final, addToolApprovalResponse } = useChat({
connection: fetchServerSentEvents("/api/recommend"),
outputSchema: RecommendationSchema,
tools: [sendEmail], // server tool with needsApproval: true
});
const last = messages.at(-1);
return (
<>
{last?.parts.map((part, i) => {
// Surface approval prompts inline, the same way Tool Approval Flow shows it.
if (
part.type === "tool-call" &&
part.state === "approval-requested" &&
part.approval
) {
return (
<ApprovalPrompt
key={i}
part={part}
onApprove={() =>
addToolApprovalResponse({ id: part.approval!.id, approved: true })
}
onDeny={() =>
addToolApprovalResponse({ id: part.approval!.id, approved: false })
}
/>
);
}
if (part.type === "thinking") return <ReasoningView key={i} text={part.text} />;
if (part.type === "tool-call") return <ToolCallView key={i} part={part} />;
return null; // hide TextPart (raw JSON when outputSchema is set)
})}
<StructuredView data={final ?? partial} />
</>
);While the approval is pending, partial stays at its last value and final stays null. As soon as the user approves (or denies and the loop resumes), the agent loop continues, the structured stream runs, and partial / final populate.
If you prefer not to use a schema library, you can pass a plain JSON Schema object:
import type { JSONSchema } from "@tanstack/ai";
const schema: JSONSchema = {
type: "object",
properties: {
name: { type: "string", description: "The person's name" },
age: { type: "number", description: "The person's age" },
},
required: ["name", "age"],
};
const result = await chat({
adapter: openaiText("gpt-5.2"),
messages: [{ role: "user", content: "Extract: John is 25 years old" }],
outputSchema: schema,
});
// Note: With plain JSON Schema, TypeScript infers `unknown` type
// You'll need to cast or validate the result yourself
const person = result as { name: string; age: number };import type { JSONSchema } from "@tanstack/ai";
const schema: JSONSchema = {
type: "object",
properties: {
name: { type: "string", description: "The person's name" },
age: { type: "number", description: "The person's age" },
},
required: ["name", "age"],
};
const result = await chat({
adapter: openaiText("gpt-5.2"),
messages: [{ role: "user", content: "Extract: John is 25 years old" }],
outputSchema: schema,
});
// Note: With plain JSON Schema, TypeScript infers `unknown` type
// You'll need to cast or validate the result yourself
const person = result as { name: string; age: number };Note: When using plain JSON Schema, TypeScript cannot infer the return type. The result will be typed as unknown. For full type safety, use a schema library like Zod.
Structured outputs are supported by all major providers through their native APIs:
| Provider | Implementation |
|---|---|
| OpenAI | Uses response_format with json_schema |
| Anthropic | Uses tool-based extraction |
| Google Gemini | Uses responseSchema |
| Ollama | Uses JSON mode with schema |
TanStack AI handles the provider-specific implementation details automatically, so you can use the same code across different providers.
Use descriptive field names and descriptions - This helps the AI understand what data to extract
Keep schemas focused - Extract only the data you need; simpler schemas produce more reliable results
Use optional fields appropriately - Mark fields as optional when the data might not be present in the source
Validate edge cases - Test with various inputs to ensure the schema handles edge cases correctly
Use enums for constrained values - When a field has a limited set of valid values, use enums:
const schema = z.object({
status: z.enum(["pending", "approved", "rejected"]),
priority: z.enum(["low", "medium", "high"]),
});const schema = z.object({
status: z.enum(["pending", "approved", "rejected"]),
priority: z.enum(["low", "medium", "high"]),
});If the AI response doesn't match your schema, TanStack AI will throw a validation error:
try {
const result = await chat({
adapter: openaiText("gpt-5.2"),
messages: [{ role: "user", content: "..." }],
outputSchema: MySchema,
});
} catch (error) {
if (error instanceof Error) {
console.error("Structured output failed:", error.message);
}
}try {
const result = await chat({
adapter: openaiText("gpt-5.2"),
messages: [{ role: "user", content: "..." }],
outputSchema: MySchema,
});
} catch (error) {
if (error instanceof Error) {
console.error("Structured output failed:", error.message);
}
}The error will include details about which fields failed validation, helping you debug schema mismatches.