Skip to content
Tutorials

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

shell
npx @tanstack/cli@latest create

Pick React. For more options, see Start getting started.

Install the TanStack AI packages and Zod:

shell
npm i @tanstack/ai @tanstack/ai-react @tanstack/ai-openrouter zod

Get 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.

ts
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.

ts
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.

tsx
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.

tsx
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().

ts
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:

  1. Run the app.
  2. Paste an OpenRouter key.
  3. 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.

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.