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.
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.
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' }),
}),
),
})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.
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.
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:
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.
The server streams the JSON text in chunks. The hook parses that incomplete JSON into partial:
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.