Streaming Structured Table
This tutorial streams typed table rows. partial.rows updates while the model writes the JSON.
Pass one schema to chat() on the server and to useChat on the client. The hook gives you partial.rows while the JSON streams, so rows show up as the model writes them. It takes about 10 minutes.
This tutorial uses React and TanStack Start. For other frameworks, open Quick Start. To skip the setup, paste a key in the sandbox in step 5.
If you already built Basic Chat, keep the app and the key form. Install zod, then go to step 2.
1. Create a Start app#
npx @tanstack/cli@latest createPick React. For more options, see Start getting started.
Install the TanStack AI packages and Zod:
npm i @tanstack/ai @tanstack/ai-react @tanstack/ai-openrouter zodpnpm add @tanstack/ai @tanstack/ai-react @tanstack/ai-openrouter zodyarn add @tanstack/ai @tanstack/ai-react @tanstack/ai-openrouter zodbun add @tanstack/ai @tanstack/ai-react @tanstack/ai-openrouter zodGet an OpenRouter key from openrouter.ai.
2. Share one schema#
Create src/lib/table-schema.ts. The client and the server both import this file. The field descriptions tell the model what to put in each column.
import { z } from 'zod'
export const tableSchema = z.object({
title: z.string().meta({ description: 'Short title for the table' }),
rows: z.array(
z.object({
name: z.string().meta({ description: 'Row name' }),
year: z.number().meta({ description: 'First release year' }),
kind: z.string().meta({ description: 'Category or paradigm' }),
note: z.string().meta({ description: 'One-line typical use' }),
}),
),
})3. Keep the key in the tab#
Create src/lib/byok.ts. memoryStorage() keeps the key in this tab only.
import { defineByok, memoryStorage } from '@tanstack/ai-react/byok'
import { openrouterByok } from '@tanstack/ai-openrouter/byok'
export const byok = defineByok({
storage: memoryStorage(),
providers: [openrouterByok],
})Create src/components/open-router-key-form.tsx. If you have this form from Basic Chat, use it again.
import { useState } from 'react'
import { openrouterByok } from '@tanstack/ai-openrouter/byok'
import { useByok } from '@tanstack/ai-react'
import { byok } from '@/lib/byok'
export function OpenRouterKeyForm() {
const snapshot = useByok(byok)
const [draft, setDraft] = useState('')
const [error, setError] = useState('')
const status = snapshot.status[openrouterByok.id]
const masked = status && 'masked' in status ? status.masked : undefined
const missingKey = snapshot.prompt?.reason === 'missing'
return (
<form
onSubmit={(event) => {
event.preventDefault()
const next = draft.trim()
if (!next) return
setError('')
void byok
.update(openrouterByok.id, next)
.then(() => setDraft(''))
.catch((caught: unknown) =>
setError(
caught instanceof Error ? caught.message : 'Could not save key',
),
)
}}
>
<input
type="password"
autoComplete="off"
placeholder={masked ? `Saved ${masked}` : 'Paste your OpenRouter key'}
value={draft}
onChange={(event) => setDraft(event.target.value)}
/>
<button type="submit" disabled={!draft.trim()}>
Save key
</button>
{missingKey ? (
<p>Paste an OpenRouter key, then send again.</p>
) : null}
{error ? <p>{error}</p> : null}
</form>
)
}For passkeys or other storage, open Bring Your Own Key.
4. Show the table from partial#
Replace src/routes/index.tsx with this file. Pass byok and outputSchema: tableSchema to useChat. Then show partial.rows as table rows.
import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
import { OpenRouterKeyForm } from '@/components/open-router-key-form'
import { byok } from '@/lib/byok'
import { tableSchema } from '@/lib/table-schema'
function TablePage() {
const [input, setInput] = useState('')
const { sendMessage, isLoading, error, stop, partial, final } = useChat({
connection: fetchServerSentEvents('/api/chat'),
threadId: 'support-chat',
byok,
outputSchema: tableSchema,
})
const rows = partial.rows ?? []
const title = partial.title
const handleSendMessage = () => {
if (!input.trim()) return
sendMessage(input.trim())
setInput('')
}
return (
<div>
<h1>Streaming structured table</h1>
<OpenRouterKeyForm />
{rows.length === 0 && !isLoading ? (
<p>Paste an OpenRouter key. Then ask for a comparison table.</p>
) : (
<table aria-busy={isLoading}>
<caption>
{title ?? 'Results'}
{isLoading ? ' (streaming)' : final ? ' (complete)' : ''}
</caption>
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Year</th>
<th scope="col">Kind</th>
<th scope="col">Note</th>
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr key={index}>
<th scope="row">{row.name ?? '…'}</th>
<td>{row.year ?? '…'}</td>
<td>{row.kind ?? '…'}</td>
<td>{row.note ?? '…'}</td>
</tr>
))}
</tbody>
</table>
)}
{error ? <p>{error.message}</p> : null}
{isLoading ? (
<button type="button" onClick={stop}>
Stop
</button>
) : null}
<label htmlFor="table-prompt">Prompt</label>
<textarea
id="table-prompt"
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder="Compare 6 JavaScript frameworks"
disabled={isLoading}
/>
<button
type="button"
onClick={handleSendMessage}
disabled={!input.trim() || isLoading}
>
Send
</button>
</div>
)
}
export const Route = createFileRoute('/')({
component: TablePage,
})A cell shows … until its field arrives.
5. Add the server route#
Create src/routes/api.chat.ts, next to index.tsx. Start maps this file name to the /api/chat path. Pass outputSchema: tableSchema and stream: true to chat().
import { createFileRoute } from '@tanstack/react-router'
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { createOpenRouterText } from '@tanstack/ai-openrouter'
import { openrouterByok } from '@tanstack/ai-openrouter/byok'
import { byokMissing, getByokKey } from '@tanstack/ai/byok/server'
import { tableSchema } from '@/lib/table-schema'
export async function POST({ request }: { request: Request }) {
const params = await chatParamsFromRequest(request)
const apiKey = getByokKey(request, openrouterByok)
if (!apiKey) return byokMissing(openrouterByok)
const stream = chat({
adapter: createOpenRouterText('openai/gpt-5.5', apiKey),
messages: params.messages,
threadId: params.threadId,
runId: params.runId,
outputSchema: tableSchema,
stream: true,
})
return toServerSentEventsResponse(stream)
}
export const Route = createFileRoute('/api/chat')({
server: {
handlers: {
POST,
},
},
})Import openrouterByok from @tanstack/ai-openrouter/byok, not from the main entry of the adapter.
Then try it:
- Run the app.
- Paste an OpenRouter key.
- Send Compare 6 JavaScript frameworks.
Rows show up while the JSON streams.
The same app is on the Examples tab at /ai/latest/docs/framework/react/examples/streaming-structured-table.
You have a table that fills from streamed structured output, and the OpenRouter key never sits in a server env file.
How it works#
The server streams the JSON text in chunks. The hook parses that incomplete JSON into partial:
- A field shows up in partial as soon as it parses.
- A new row can have empty cells until more text arrives.
- final is the complete object. It stays null until the stream ends.
Show partial.rows while the model writes. Use final when you need the complete object, for example to save it. If you read only final, the table stays empty until the run is done.
On the server, getByokKey reads the x-byok-openrouter header first. If the header is empty, it reads OPENROUTER_API_KEY from the environment. If both are empty, byokMissing returns HTTP 401. If no key is saved in the tab, the hook does not send the POST, and the form shows "Paste an OpenRouter key, then send again."
The full example is on GitHub: TanStack/ai examples/react/streaming-structured-table.
For the API details, open Streaming UIs.