Quick Start: React Native
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:
- The URL is absolute. The app is not served from your backend, so /api/chat does not work.
- The transport is XHR. Start with xhrHttpStream(). Streaming fetch is often missing on phones.
1. Install#
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.
2. Create the server#
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-...3. Start the server#
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.
4. Set the server URL#
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:
- iOS simulator: http://127.0.0.1:8787 often works.
- Android emulator: http://10.0.2.2:8787.
- Physical device: the LAN IP of your computer, for example http://192.168.1.10:8787, or a tunneled HTTPS URL.
Expo bundles only EXPO_PUBLIC_* values into the app. Keep provider keys as plain server variables, such as OPENAI_API_KEY.
5. Add the chat screen#
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>
)
}6. Try it#
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.
How it works#
- sendMessage posts the messages to /chat/http through xhrHttpStream().
- The Hono route calls chat() and returns newline-delimited JSON with toHttpResponse().
- xhrHttpStream() reads the response through XHR progress events and updates messages as chunks arrive.
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().
Advanced#
Choose a transport#
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:
- Response.body
- Response.body.getReader()
- TextDecoder
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.
Run the Expo recipe example#
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:
envOPENAI_API_KEY=sk-... OPENAI_MODEL=gpt-5.2Run the example:
shellpnpm --filter ts-react-native-chat devThe command starts:
- Hono on 0.0.0.0:8787.
- Expo and Metro in LAN mode.
- EXPO_PUBLIC_TANSTACK_AI_BASE_URL=http://<lan-ip>:8787, when it finds a LAN address.
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.
Troubleshooting#
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 phone and the computer are on the same Wi-Fi network.
- Client isolation is off on the network.
- Your firewall lets Node.js use the Hono port and Metro port 8081.
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:
- Android Studio installed the SDK.
- An emulator exists in Device Manager.
- adb is on PATH. On Windows, look for %LOCALAPPDATA%\Android\Sdk\platform-tools\adb.exe.
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:
- OPENAI_API_KEY is missing.
- OPENAI_MODEL names a model that the adapter does not support (in the example app).
- xhrServerSentEvents() points at /chat/http instead of /chat/sse, or the reverse.