Skip to content
Getting Started

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:

shell
npx create-expo-app@latest my-ai-chat

Install TanStack AI, the React hooks, the OpenAI adapter for the server, and Hono for the example server:

shell
npm i @tanstack/ai @tanstack/ai-react @tanstack/ai-openai hono @hono/node-server zod

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

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:

env
OPENAI_API_KEY=sk-...

3. Start the server

Install tsx to run the TypeScript file:

shell
npm i -D tsx

Add a script and run it:

shell
pnpm pkg set scripts.dev:server="tsx server.ts"
pnpm dev:server

Keep the server running while you use the app.

4. Set the server URL

Give Expo the server address in a public variable:

env
EXPO_PUBLIC_TANSTACK_AI_BASE_URL=http://192.168.1.10:8787

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

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

  1. sendMessage posts the messages to /chat/http through xhrHttpStream().
  2. The Hono route calls chat() and returns newline-delimited JSON with toHttpResponse().
  3. 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 runtimeClient adapterServer response
Most Expo and React Native appsxhrHttpStream(url) with /chat/httptoHttpResponse(stream)
A runtime or proxy path that supports SSExhrServerSentEvents(url) with /chat/ssetoServerSentEventsResponse(stream)
A runtime with streaming fetchfetchHttpStream(url) with /chat/httptoHttpResponse(stream)

To serve SSE, add this route to server.ts and import toServerSentEventsResponse from @tanstack/ai:

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

  1. Create examples/ts-react-native-chat/.env:

    env
    OPENAI_API_KEY=sk-...
    OPENAI_MODEL=gpt-5.2
  2. Run the example:

    shell
    pnpm --filter ts-react-native-chat dev

    The 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.
  3. 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.