Skip to content

blog: Structured Output That Remembers Across Turns#930

Merged
AlemTuzlak merged 2 commits into
TanStack:mainfrom
AlemTuzlak:blog/multi-turn-structured-output
May 19, 2026
Merged

blog: Structured Output That Remembers Across Turns#930
AlemTuzlak merged 2 commits into
TanStack:mainfrom
AlemTuzlak:blog/multi-turn-structured-output

Conversation

@AlemTuzlak
Copy link
Copy Markdown
Contributor

@AlemTuzlak AlemTuzlak commented May 19, 2026

Summary

Follow-up blog post to Stop Waiting on JSON: Stream Structured Output with One Schema (#?, merged 2026-05-14). Covers the multi-turn shape that just landed in TanStack AI (TanStack/ai#577):

  • Every assistant turn now carries its own typed `structured-output` part on its `UIMessage` — history is preserved by default.
  • The schema generic threads all the way down to `messages[i].parts[j].data` (no cast required, in any of React / Vue / Solid / Svelte).
  • The wire layer round-trips prior `structured-output` parts as assistant content (`part.raw`), so the LLM sees its own prior structured responses on follow-up turns.

The post walks through what changed, the manual-tracking workarounds the new shape replaces, and the recipe-builder pattern end-to-end (server route + client component, ~80 lines total).

Files

  • `src/blog/multi-turn-structured-output.md` — the post (~1900 words, 30-second read)
  • `public/blog-assets/multi-turn-structured-output/header.png` — promo image

Companion content

The same release also shipped:

  • 5 reorganized docs pages under `docs/structured-outputs/{overview,one-shot,streaming,multi-turn,with-tools}.md`
  • A new `/generations/structured-chat` example in `examples/ts-react-chat`
  • Framework parity for `@tanstack/ai-vue`, `@tanstack/ai-solid`, `@tanstack/ai-svelte`

Test plan

Summary by CodeRabbit

  • Documentation
    • New blog post on multi-turn structured output: explains how typed structured data now persists across turns, preserves model-produced raw JSON for round-trip coherence, and shows an end-to-end recipe-builder example (streaming server + React client). Covers how type generics flow through supported frameworks and offers guidance on one-shot vs streaming vs multi-turn usage with links to related examples.

Review Change Stack

Follow-up to "Stop Waiting on JSON" — covers the multi-turn shape
shipped in TanStack AI: every assistant turn now carries its own
typed StructuredOutputPart on its UIMessage, history is preserved by
default, and the schema generic threads all the way down to
messages[i].parts[j].data with no cast required.

Includes the recipe-builder pattern end-to-end (server route + client
component), the round-trip story (part.raw → assistant content so the
LLM sees its own prior structured responses on follow-ups), and a
note on framework parity (React, Vue, Solid, Svelte — Preact pending).
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 19, 2026

📝 Walkthrough

Walkthrough

Adds a blog post describing per-assistant-message typed structured-output parts that persist across turns, including server streaming and React client examples, round-trip coherence via preserved raw JSON, framework TSchema propagation, and guidance for one-shot vs streaming vs multi-turn usage.

Changes

Multi-turn Structured Output Documentation

Layer / File(s) Summary
Post metadata and opening narrative
src/blog/multi-turn-structured-output.md
Front matter, hero image, and opening narrative contrasting old clobbering behavior with message-persistent structured output across turns.
Prior useChat single-slot behavior
src/blog/multi-turn-structured-output.md
Documents the old useChat({ outputSchema }) shape where partial/final were scoped to the most recent run and required manual state/typing workarounds for multi-turn refinement.
Structured-output part definition
src/blog/multi-turn-structured-output.md
Defines the new structured-output part on assistant messages with status, partial, data, raw, and optional error/reasoning fields; shows how to traverse messages[] for typed rendering.
Server streaming recipe-builder example
src/blog/multi-turn-structured-output.md
Server route example using outputSchema and streaming to emit structured-output events to the client.
Client React useChat rendering example
src/blog/multi-turn-structured-output.md
React useChat example rendering recipe history directly from each assistant message’s structured-output part without separate recipes[] state or onFinish sync.
Multi-turn demo
src/blog/multi-turn-structured-output.md
Short try-it sequence demonstrating multi-turn refinement where earlier assistant structured outputs remain visible after later turns.
Round-trip coherence and TSchema propagation
src/blog/multi-turn-structured-output.md
Explains serializing completed structured outputs back to the model using preserved raw JSON (with fallbacks), and summarizes how the TSchema generic propagates through supported framework packages.
When to use & links
src/blog/multi-turn-structured-output.md
Guidance distinguishing one-shot vs streaming vs multi-turn patterns and links to the example UI and multi-turn docs.

Estimated code review effort

🎯 1 (Trivial) | ⏱️ ~5 minutes

Poem

🐇 A rabbit scribbles notes in the light,
Of messages holding payloads tight,
Across turns they linger, typed and true,
Raw JSON kept for the model's view,
Hopping through history — structured and bright!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically summarizes the main change: a blog post about multi-turn structured output that persists across conversation turns.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/blog/multi-turn-structured-output.md`:
- Around line 197-201: The example renders <li> elements directly inside the
<article> using recipe.ingredients?.map, which is invalid HTML; wrap the mapped
items in a list container (e.g., add a <ul> around the
{recipe.ingredients?.map(...)} block) so the <li> elements are children of a
proper list element.
- Line 143: The import of RecipeSchema and Recipe from './api/recipes' pulls a
server-only route module into client code; move the shared types/schema into a
new shared module (e.g., app/lib/recipe-schema.ts) and update all imports to use
that module: extract the RecipeSchema and the Recipe type from './api/recipes'
into the new file, export them, then replace import { RecipeSchema, type Recipe
} from './api/recipes' with import { RecipeSchema, type Recipe } from
'app/lib/recipe-schema' in the client-facing files and update the API route to
import the same shared module.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3a835830-5415-4f5b-b5c9-24932e7dcc6c

📥 Commits

Reviewing files that changed from the base of the PR and between ea82967 and 432ca69.

📒 Files selected for processing (1)
  • src/blog/multi-turn-structured-output.md

import { useState } from 'react'
import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
import type { StructuredOutputPart } from '@tanstack/ai-client'
import { RecipeSchema, type Recipe } from './api/recipes'
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In Next.js App Router, is importing app/api/**/route.ts modules into client components supported/recommended, or should shared schema/types live in a separate module?

💡 Result:

In Next.js App Router, importing app/api//route.ts (or other server-only route modules) into a Client Component is not the supported/recommended way to share code. Instead, keep shared schema/types in a separate module (or package) that both the route handler and the client code can import, and call the API route via fetch from the client. Why: 1) Next.js Client Components have their own module graph. Once you mark a file with "use client", all of its imports become part of the client bundle/module graph [1]. Importing a route module from app/api//route.ts would therefore attempt to pull server route code into the client boundary. 2) Next.js explicitly warns that you can accidentally import server-only code into the client, and recommends using server-only (and client-only) to prevent this kind of mistake with clearer build-time errors [1]. Recommended pattern: - Define shared types/schemas in lib/ (or a shared package in a monorepo). - In app/api/.../route.ts, import those shared types/schemas for request validation/response typing. - In Client Components, import only the shared types/schemas, and interact with the route through HTTP (fetch('/api/...')) rather than importing the route module itself. Evidence that the supported direction is “client calls API route”, not “client imports route.ts”: community guidance/examples show creating an API route under app/api/.../route.ts and then calling it from a client component with fetch('/api/...') [2].

Citations:


Move shared schema to a separate module to avoid importing server code into the client.

Importing ./api/recipes pulls the API route module into the client bundle. In Next.js App Router, route modules are server-only and should not be imported directly from Client Components. Define RecipeSchema and Recipe in a shared module (e.g., app/lib/recipe-schema.ts) that both the route handler and client can safely import.

Suggested doc-snippet fix
+// app/lib/recipe-schema.ts
+import { z } from 'zod'
+
+export const RecipeSchema = z.object({
+  title: z.string(),
+  cuisine: z.string(),
+  servings: z.number(),
+  estimatedCostUsd: z.number(),
+  ingredients: z.array(z.object({ item: z.string(), amount: z.string() })),
+  steps: z.array(z.string()),
+  tips: z.array(z.string()),
+})
+
+export type Recipe = z.infer<typeof RecipeSchema>
+
 // app/api/recipes/route.ts
 import { chat, toServerSentEventsResponse } from '`@tanstack/ai`'
 import { openaiText } from '`@tanstack/ai-openai`'
-import { z } from 'zod'
-
-export const RecipeSchema = z.object({
-  title: z.string(),
-  cuisine: z.string(),
-  servings: z.number(),
-  estimatedCostUsd: z.number(),
-  ingredients: z.array(z.object({ item: z.string(), amount: z.string() })),
-  steps: z.array(z.string()),
-  tips: z.array(z.string()),
-})
-
-export type Recipe = z.infer<typeof RecipeSchema>
+import { RecipeSchema } from '`@/app/lib/recipe-schema`'
-import { RecipeSchema, type Recipe } from './api/recipes'
+import { RecipeSchema, type Recipe } from '`@/app/lib/recipe-schema`'
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { RecipeSchema, type Recipe } from './api/recipes'
import { RecipeSchema, type Recipe } from '`@/app/lib/recipe-schema`'
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/blog/multi-turn-structured-output.md` at line 143, The import of
RecipeSchema and Recipe from './api/recipes' pulls a server-only route module
into client code; move the shared types/schema into a new shared module (e.g.,
app/lib/recipe-schema.ts) and update all imports to use that module: extract the
RecipeSchema and the Recipe type from './api/recipes' into the new file, export
them, then replace import { RecipeSchema, type Recipe } from './api/recipes'
with import { RecipeSchema, type Recipe } from 'app/lib/recipe-schema' in the
client-facing files and update the API route to import the same shared module.

Comment on lines +197 to +201
{recipe.ingredients?.map((ing, i) => (
<li key={i}>
{ing?.amount} {ing?.item}
</li>
))}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Wrap <li> elements in a list container in the example.

The snippet renders <li> directly inside <article>, which is invalid list structure and easy for readers to cargo-cult. Add a <ul> around mapped ingredients.

Suggested doc-snippet fix
-      {recipe.ingredients?.map((ing, i) => (
-        <li key={i}>
-          {ing?.amount} {ing?.item}
-        </li>
-      ))}
+      <ul>
+        {recipe.ingredients?.map((ing, i) => (
+          <li key={i}>
+            {ing?.amount} {ing?.item}
+          </li>
+        ))}
+      </ul>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{recipe.ingredients?.map((ing, i) => (
<li key={i}>
{ing?.amount} {ing?.item}
</li>
))}
<ul>
{recipe.ingredients?.map((ing, i) => (
<li key={i}>
{ing?.amount} {ing?.item}
</li>
))}
</ul>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/blog/multi-turn-structured-output.md` around lines 197 - 201, The example
renders <li> elements directly inside the <article> using
recipe.ingredients?.map, which is invalid HTML; wrap the mapped items in a list
container (e.g., add a <ul> around the {recipe.ingredients?.map(...)} block) so
the <li> elements are children of a proper list element.

@AlemTuzlak AlemTuzlak merged commit 993babf into TanStack:main May 19, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants