Basic Chat
This tutorial builds a chat that streams tokens. The provider key stays in the browser for the demo.
The user pastes an OpenRouter key, and useChat sends it to a server route. 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.
1. Create a Start app#
npx @tanstack/cli@latest createPick React. For more options, see Start getting started.
Install the TanStack AI packages:
npm i @tanstack/ai @tanstack/ai-react @tanstack/ai-openrouterpnpm add @tanstack/ai @tanstack/ai-react @tanstack/ai-openrouteryarn add @tanstack/ai @tanstack/ai-react @tanstack/ai-openrouterbun add @tanstack/ai @tanstack/ai-react @tanstack/ai-openrouterGet an OpenRouter key from openrouter.ai.
2. 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. byok.update saves the key. useByok reads the status.
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.
3. Add the chat page#
Replace src/routes/index.tsx with this file. Pass byok to useChat. The hook then sends the key in an x-byok-* header.
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'
function ChatPage() {
const [input, setInput] = useState('')
const { messages, sendMessage, isLoading, error, stop } = useChat({
connection: fetchServerSentEvents('/api/chat'),
threadId: 'support-chat',
byok,
})
const handleSendMessage = () => {
if (!input.trim()) return
sendMessage(input.trim())
setInput('')
}
return (
<div>
<OpenRouterKeyForm />
{messages.map((message) => (
<div key={message.id}>
{message.parts.map((part, index) =>
part.type === 'text' ? <p key={index}>{part.content}</p> : null,
)}
</div>
))}
{error ? <p>{error.message}</p> : null}
{isLoading ? (
<button type="button" onClick={stop}>
Stop
</button>
) : null}
<textarea
value={input}
onChange={(event) => setInput(event.target.value)}
disabled={isLoading}
/>
<button
type="button"
onClick={handleSendMessage}
disabled={!input.trim() || isLoading}
>
Send
</button>
</div>
)
}
export const Route = createFileRoute('/')({
component: ChatPage,
})4. Add the server route#
Create src/routes/api.chat.ts, next to index.tsx. Start maps this file name to the /api/chat path.
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'
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,
})
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.
5. Try it#
- Run the app.
- Paste an OpenRouter key.
- Send a message.
Tokens stream into the page. Click Stop to cancel the reply.
The same app is on the Examples tab at /ai/latest/docs/framework/react/examples/basic-chat.
You have a streaming chat, and the OpenRouter key never sits in a server env file.
How it works#
A chat has two sides:
- The client runs in the browser. It holds the key, shows the messages, and sends a POST to your route.
- The server route reads the key, calls OpenRouter, and streams the tokens back.
On the client:
- useChat keeps messages up to date as tokens arrive.
- If no key is saved, the hook does not send the POST. The form shows "Paste an OpenRouter key, then send again."
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.
- toServerSentEventsResponse turns the chat() stream into a Server-Sent Events response.
The full example is on GitHub: TanStack/ai examples/react/basic-chat.
Next, pick one:
- A headless chat UI: A chat box with no tools.
- An image from a prompt: Generate Image.
- A table that fills as JSON streams: Streaming Structured Table.