blog: Structured Output That Remembers Across Turns#930
Conversation
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).
📝 WalkthroughWalkthroughAdds a blog post describing per-assistant-message typed ChangesMulti-turn Structured Output Documentation
Estimated code review effort🎯 1 (Trivial) | ⏱️ ~5 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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
📒 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' |
There was a problem hiding this comment.
🧩 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:
- 1: https://nextjs.org/docs/app/getting-started/server-and-client-components
- 2: https://docs.importcsv.com/getting-started/nextjs
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.
| 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.
| {recipe.ingredients?.map((ing, i) => ( | ||
| <li key={i}> | ||
| {ing?.amount} {ing?.item} | ||
| </li> | ||
| ))} |
There was a problem hiding this comment.
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.
| {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.
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):
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
Companion content
The same release also shipped:
Test plan
Summary by CodeRabbit