{"slug": "stop-waiting-on-json-stream-structured-output-with-one-schema", "title": "Stop Waiting on JSON: Stream Structured Output with One Schema", "summary": "Tanstack has released a new `useChat({ outputSchema })` feature that eliminates the need for manual JSON parsing and type casting when streaming structured output from large language models. The update allows developers to pass a single schema to the hook and receive typed partial and final objects directly, removing the boilerplate code previously required for handling streaming JSON responses. The feature works across multiple providers including OpenAI, OpenRouter, Grok, Groq, and Ollama, and includes improved type safety for structured output streams, tagged event variants, and per-chunk debug logging.", "body_md": "*by Alem Tuzlak on May 14, 2026.*\n\nYou ask an LLM for a Person object. You hit send. The spinner spins. Five seconds. Ten. Twenty. Somewhere on a server in Oregon, the model is happily generating tokens, and your user is staring at a loading state until the very last } of the JSON arrives.\n\nThat UX is bad and you already know it. The fix, in theory, is \"just stream it.\" The fix, in practice, has been writing 15 lines of glue: an onChunk handler, a useState, a parsePartialJSON call, a manual cast to your Person type. Repeat in every project. Hope you got the types right.\n\nThis release kills the glue. useChat({ outputSchema }) now gives you a typed partial and final straight from the hook. One schema. End to end.\n\nUntil now, mixing streaming with outputSchema in @tanstack/ai looked something like this on the client:\n\n```\nconst [partial, setPartial] = useState<Partial<Person>>({})\nconst [final, setFinal] = useState<Person | null>(null)\nlet raw = ''\n\nuseChat({\n  connection: fetchServerSentEvents('/api/extract'),\n  onChunk: (chunk) => {\n    if (chunk.type === 'TEXT_MESSAGE_CONTENT') {\n      raw += chunk.delta\n      setPartial(parsePartialJSON(raw))\n    } else if (chunk.name === 'structured-output.complete') {\n      setFinal(chunk.value.object as Person)\n    }\n  },\n})\nconst [partial, setPartial] = useState<Partial<Person>>({})\nconst [final, setFinal] = useState<Person | null>(null)\nlet raw = ''\n\nuseChat({\n  connection: fetchServerSentEvents('/api/extract'),\n  onChunk: (chunk) => {\n    if (chunk.type === 'TEXT_MESSAGE_CONTENT') {\n      raw += chunk.delta\n      setPartial(parsePartialJSON(raw))\n    } else if (chunk.name === 'structured-output.complete') {\n      setFinal(chunk.value.object as Person)\n    }\n  },\n})\n```\n\nThis works, but every byte of it is something you didn't want to write:\n\nThe schema lives on the server. The same schema would happily describe partial and final on the client. There was no reason for the client to be guessing.\n\nPass the schema. Get the types back.\n\n``` js\nimport { useChat } from '@tanstack/ai-react'\nimport { fetchServerSentEvents } from '@tanstack/ai-client/event-client'\nimport { PersonSchema } from './schemas'\n\nconst { partial, final, status } = useChat({\n  connection: fetchServerSentEvents('/api/extract'),\n  outputSchema: PersonSchema,\n})\njs\nimport { useChat } from '@tanstack/ai-react'\nimport { fetchServerSentEvents } from '@tanstack/ai-client/event-client'\nimport { PersonSchema } from './schemas'\n\nconst { partial, final, status } = useChat({\n  connection: fetchServerSentEvents('/api/extract'),\n  outputSchema: PersonSchema,\n})\n```\n\nThat's the whole thing.\n\nThe same hook returns the message stream you already use, so partial UI previews and chat transcripts live side-by-side without conflict.\n\nThe headline is the hook, but the work that made it possible touches the whole stack.\n\n**A real type for the structured-output stream.** chat({ outputSchema, stream: true }) now returns a StructuredOutputStream<T> that's a proper discriminated union: every regular StreamChunk plus a single tagged StructuredOutputCompleteEvent<T> carrying a strongly-typed value.object. You no longer fight any when you destructure or switch on the event.\n\n**Tagged variants for the other custom events too.** While we were in there, ApprovalRequestedEvent and ToolInputAvailableEvent became their own tagged shapes. Tool-calling flows narrow cleanly without a helper.\n\n**Per-chunk debug logging in the structured path.** The streaming structured output path now calls logger.provider on every chunk, matching the behavior of plain chatStream. Provider-level debugging is no longer a black box just because you opted into a schema.\n\n**Provider coverage.** OpenAI, OpenRouter, Grok, Groq, and Ollama (anything riding the openai-base) all go through the same streaming structured output pipeline. The summarize adapter got the same treatment, so structured summaries stream end-to-end too.\n\n**Framework parity.** useChat({ outputSchema }) works in @tanstack/ai-react, @tanstack/ai-vue, @tanstack/ai-solid, and @tanstack/ai-svelte. Same return shape, same types, same behavior.\n\nThere's a temptation, when designing this kind of API, to invent a separate hook: useStructuredChat, useTypedChat, something parallel. That would have been a mistake. A schema isn't a different mode of chatting, it's just extra information the hook can use.\n\nBy folding outputSchema into the existing useChat, the upgrade path from \"I'm using a chat hook\" to \"I'm using a chat hook with typed streaming output\" is *literally one prop*. Your tool wiring, your approval prompts, your transcript state, everything that was already on useChat still works exactly the same way. The new behavior only exists for the keys that depend on the schema.\n\nThe cost of \"one more hook for a slightly different case\" is paid in docs, in user confusion, and in the long tail of useFooButSometimesBar hooks people inevitably accumulate. We'd rather not.\n\nInstall the latest:\n\n```\npnpm add @tanstack/ai @tanstack/ai-react @tanstack/ai-openai zod\npnpm add @tanstack/ai @tanstack/ai-react @tanstack/ai-openai zod\n```\n\nDefine the schema once. Use it on both sides.\n\n``` js\n// schemas.ts\nimport { z } from 'zod'\n\nexport const PersonSchema = z.object({\n  name: z.string(),\n  age: z.number(),\n  email: z.string().email(),\n})\njs\n// schemas.ts\nimport { z } from 'zod'\n\nexport const PersonSchema = z.object({\n  name: z.string(),\n  age: z.number(),\n  email: z.string().email(),\n})\n```\n\nOn the server, hand the schema to chat and stream the response as Server-Sent Events:\n\n``` js\n// app/api/extract/route.ts\nimport { chat, toServerSentEventsResponse } from '@tanstack/ai'\nimport { openaiText } from '@tanstack/ai-openai/adapters'\nimport { PersonSchema } from './schemas'\n\nexport async function POST(req: Request) {\n  const { messages } = await req.json()\n\n  const stream = chat({\n    adapter: openaiText('gpt-5.2'),\n    messages,\n    outputSchema: PersonSchema,\n    stream: true,\n  })\n\n  return toServerSentEventsResponse(stream)\n}\njs\n// app/api/extract/route.ts\nimport { chat, toServerSentEventsResponse } from '@tanstack/ai'\nimport { openaiText } from '@tanstack/ai-openai/adapters'\nimport { PersonSchema } from './schemas'\n\nexport async function POST(req: Request) {\n  const { messages } = await req.json()\n\n  const stream = chat({\n    adapter: openaiText('gpt-5.2'),\n    messages,\n    outputSchema: PersonSchema,\n    stream: true,\n  })\n\n  return toServerSentEventsResponse(stream)\n}\n```\n\nOn the client, point useChat at that endpoint and pass the same schema:\n\n``` js\n// app/extract/page.tsx\nimport { useChat } from '@tanstack/ai-react'\nimport { fetchServerSentEvents } from '@tanstack/ai-client/event-client'\nimport { PersonSchema } from './schemas'\n\nconst { partial, final } = useChat({\n  connection: fetchServerSentEvents('/api/extract'),\n  outputSchema: PersonSchema,\n})\n\nreturn (\n  <form>\n    <input value={partial.name ?? ''} readOnly />\n    <input value={partial.age ?? ''} readOnly />\n    <input value={partial.email ?? ''} readOnly />\n    {final && <p>Done. Extracted a fully-validated Person.</p>}\n  </form>\n)\njs\n// app/extract/page.tsx\nimport { useChat } from '@tanstack/ai-react'\nimport { fetchServerSentEvents } from '@tanstack/ai-client/event-client'\nimport { PersonSchema } from './schemas'\n\nconst { partial, final } = useChat({\n  connection: fetchServerSentEvents('/api/extract'),\n  outputSchema: PersonSchema,\n})\n\nreturn (\n  <form>\n    <input value={partial.name ?? ''} readOnly />\n    <input value={partial.age ?? ''} readOnly />\n    <input value={partial.email ?? ''} readOnly />\n    {final && <p>Done. Extracted a fully-validated Person.</p>}\n  </form>\n)\n```\n\nRead the full guide: [tanstack.com/ai/docs/chat/structured-outputs](https://tanstack.com/ai/docs/chat/structured-outputs).\n\nDrop the glue. Pass the schema. Ship the feature.", "url": "https://wpnews.pro/news/stop-waiting-on-json-stream-structured-output-with-one-schema", "canonical_source": "https://tanstack.com/blog/streaming-structured-output", "published_at": "2026-05-14 12:00:00+00:00", "updated_at": "2026-05-27 08:07:22.611833+00:00", "lang": "en", "topics": ["ai-tools", "ai-products", "large-language-models", "artificial-intelligence"], "entities": ["Alem Tuzlak", "@tanstack/ai", "Person"], "alternates": {"html": "https://wpnews.pro/news/stop-waiting-on-json-stream-structured-output-with-one-schema", "markdown": "https://wpnews.pro/news/stop-waiting-on-json-stream-structured-output-with-one-schema.md", "text": "https://wpnews.pro/news/stop-waiting-on-json-stream-structured-output-with-one-schema.txt", "jsonld": "https://wpnews.pro/news/stop-waiting-on-json-stream-structured-output-with-one-schema.jsonld"}}