This guide adds a streaming chat to a React Native or Expo app. The provider key stays on your server.
Put the model behind a small server route. The app calls it with useChat from @tanstack/ai-react and streams over XHR. This takes about 10 minutes.
Two things differ from the web Quick Start:
If you start from scratch, create an Expo app first:
npx create-expo-app@latest my-ai-chatpnpx create-expo-app@latest my-ai-chatyarn dlx create-expo-app@latest my-ai-chatbunx create-expo-app@latest my-ai-chatInstall TanStack AI, the React hooks, the OpenAI adapter for the server, and Hono for the example server:
npm i @tanstack/ai @tanstack/ai-react @tanstack/ai-openai hono @hono/node-server zodpnpm add @tanstack/ai @tanstack/ai-react @tanstack/ai-openai hono @hono/node-server zodyarn add @tanstack/ai @tanstack/ai-react @tanstack/ai-openai hono @hono/node-server zodbun add @tanstack/ai @tanstack/ai-react @tanstack/ai-openai hono @hono/node-server zodIf your Expo app is in a workspace, run the command from the app package, or use your workspace filter.
The server owns the model and the API key. The native app never imports @tanstack/ai-openai and never sees OPENAI_API_KEY.
Create server.ts:
import { serve } from '@hono/node-server'
import { chat, toHttpResponse } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { Hono } from 'hono'
const app = new Hono()
app.get('/health', (c) => c.json({ ok: true }))
app.post('/chat/http', async (c) => {
if (!process.env.OPENAI_API_KEY) {
throw new Error('OPENAI_API_KEY is not configured on the server')
}
const body = await c.req.json()
const stream = chat({
adapter: openaiText('gpt-6.1-sol'),
messages: body.messages,
threadId: body.threadId,
})
return toHttpResponse(stream, {
headers: {
'Content-Type': 'application/x-ndjson',
'Cache-Control': 'no-cache',
},
})
})
serve({
fetch: app.fetch,
hostname: '0.0.0.0',
port: Number(process.env.PORT ?? 8787),
})Set the key where the Hono process runs:
OPENAI_API_KEY=sk-...Install tsx to run the TypeScript file:
npm i -D tsxpnpm add -D tsxyarn add -D tsxbun add -d tsxAdd a script and run it:
pnpm pkg set scripts.dev:server="tsx server.ts"
pnpm dev:serverKeep the server running while you use the app.
Give Expo the server address in a public variable:
EXPO_PUBLIC_TANSTACK_AI_BASE_URL=http://192.168.1.10:8787Use an address that your device can reach:
Expo bundles only EXPO_PUBLIC_* values into the app. Keep provider keys as plain server variables, such as OPENAI_API_KEY.
Create ChatScreen.tsx:
import { useState } from 'react'
import { Button, ScrollView, Text, TextInput, View } from 'react-native'
import { useChat, xhrHttpStream } from '@tanstack/ai-react'
const baseUrl =
process.env.EXPO_PUBLIC_TANSTACK_AI_BASE_URL ?? 'http://127.0.0.1:8787'
export function ChatScreen() {
const [input, setInput] = useState('')
const { messages, sendMessage, isLoading, error } = useChat({
connection: xhrHttpStream(`${baseUrl}/chat/http`),
threadId: 'support-chat',
})
async function send() {
const text = input.trim()
if (!text || isLoading) return
setInput('')
await sendMessage(text)
}
return (
<View style={{ flex: 1, padding: 24, gap: 12 }}>
<ScrollView style={{ flex: 1 }}>
{messages.map((message) => (
<View key={message.id} style={{ marginBottom: 16 }}>
<Text style={{ fontWeight: '700' }}>{message.role}</Text>
{message.parts.map((part, index) =>
part.type === 'text' ? (
<Text key={index}>{part.content}</Text>
) : null,
)}
</View>
))}
</ScrollView>
{error ? <Text style={{ color: 'crimson' }}>{error.message}</Text> : null}
<TextInput
value={input}
onChangeText={setInput}
editable={!isLoading}
placeholder="Ask for a recipe..."
style={{ borderWidth: 1, borderRadius: 8, padding: 12 }}
/>
<Button title={isLoading ? 'Streaming...' : 'Send'} onPress={send} />
</View>
)
}Render ChatScreen in your app and open it on a device or simulator. Send a message. The answer streams in, and the provider key stays on the server.
XHR progress events are usually more reliable on phone runtimes than streaming fetch.
Note
Each client transport expects one server response format. xhrHttpStream() and fetchHttpStream() read the newline-delimited JSON from toHttpResponse(). xhrServerSentEvents() reads the text/event-stream from toServerSentEventsResponse().
Use the transport that matches your server route and your runtime:
| Native runtime | Client adapter | Server response |
|---|---|---|
| Most Expo and React Native apps | xhrHttpStream(url) with /chat/http | toHttpResponse(stream) |
| A runtime or proxy path that supports SSE | xhrServerSentEvents(url) with /chat/sse | toServerSentEventsResponse(stream) |
| A runtime with streaming fetch | fetchHttpStream(url) with /chat/http | toHttpResponse(stream) |
To serve SSE, add this route to server.ts and import toServerSentEventsResponse from @tanstack/ai:
app.post('/chat/sse', async (c) => {
const body = await c.req.json()
const stream = chat({
adapter: openaiText('gpt-6.1-sol'),
messages: body.messages,
threadId: body.threadId,
})
return toServerSentEventsResponse(stream)
})Use fetchHttpStream() only when your runtime supports all of these:
If one of them is missing, the adapter throws UnsupportedResponseStreamError. A polyfilled fetch that buffers the whole response is not enough. TanStack AI needs the response bytes as they arrive to update the chat during the stream.
For headers, credentials, withCredentials, and dynamic URLs, see Request Options.
The repo has an Expo app for React Native. It runs a local Hono and OpenAI server, has a transport selector, and streams structured recipe cards. Use it to test native chat and structured output together.
Create examples/ts-react-native-chat/.env:
OPENAI_API_KEY=sk-...
OPENAI_MODEL=gpt-5.2Run the example:
pnpm --filter ts-react-native-chat devThe command starts:
Scan the Expo Go QR code from a phone on the same Wi-Fi network.
In the app, use the Testing mode panel to switch between Fetch HTTP, XHR HTTP, and XHR SSE. The recipe card streams structured fields: title, ingredients, steps, tips, warnings, and revision. Follow-up prompts revise the same recipe.
For commands and network overrides, see examples/ts-react-native-chat/README.md.
http://localhost:8081 shows JSON. This is correct. Port 8081 is the Metro manifest and bundle server, not a web UI. Open the app from Expo Go, an Android emulator, or an iOS simulator.
A physical device cannot reach the backend. Open http://<lan-ip>:8787/health in the phone browser. If it does not return {"ok":true}, make sure that:
The Android emulator cannot reach 127.0.0.1. Set EXPO_PUBLIC_TANSTACK_AI_BASE_URL to http://10.0.2.2:8787. Android emulators map 10.0.2.2 to the host machine.
Expo prints Android SDK or adb warnings. This is an Android tooling error, not a TanStack AI transport error. Make sure that:
The phone logs UnsupportedResponseStreamError. Your runtime does not have streaming fetch, Response.body.getReader(), or TextDecoder. Switch from fetchHttpStream() to xhrHttpStream() or xhrServerSentEvents(). Fetch polyfills work only if they give a real incremental readable stream.
XHR reports a server error. Read the Hono server terminal first. The usual causes: