A chat screen with streaming AI responses is one of the most-requested features in mobile apps right now. With Expo Router API routes and the Vercel AI SDK, you can build one end to end in a single Expo project: server and app together, with your API key safely on the server.
Architecture
Expo app (useChat) ──stream──▶ app/api/chat+api.ts ──▶ LLM provider
expo/fetch streamText() (Claude, GPT, ...)
The app never sees the provider API key. It only talks to your API route.
1. Create the project and install
npx create-expo-app@latest my-ai-app
cd my-ai-app
npx expo install ai @ai-sdk/react zod @ungap/structured-clone @stardazed/streams-text-encoding
The last two packages are polyfills that streaming needs on iOS and Android.
2. Add the polyfills
// polyfills.ts
import { Platform } from "react-native"
import structuredClone from "@ungap/structured-clone"
if (Platform.OS !== "web") {
const setup = async () => {
const { polyfillGlobal } = await import("react-native/Libraries/Utilities/PolyfillFunctions")
const { TextEncoderStream, TextDecoderStream } = await import("@stardazed/streams-text-encoding")
if (!("structuredClone" in global)) polyfillGlobal("structuredClone", () => structuredClone)
polyfillGlobal("TextEncoderStream", () => TextEncoderStream)
polyfillGlobal("TextDecoderStream", () => TextDecoderStream)
}
setup()
}
export {}
Import it once at the top of app/_layout.tsx:
import "@/polyfills"
On Expo SDK 58, deep
react-native/Libraries/*imports are type errors under the strict TypeScript API. Keep this file as-is, and add a// @ts-expect-erroron that import line if your type check complains.
3. Write the API route
// app/api/chat+api.ts
import {
convertToModelMessages,
createUIMessageStreamResponse,
streamText,
toUIMessageStream,
type UIMessage,
} from "ai"
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json()
const result = streamText({
model: "anthropic/claude-sonnet-5.5",
system: "You are a concise, friendly assistant inside a mobile app.",
messages: await convertToModelMessages(messages),
})
return createUIMessageStreamResponse({
stream: toUIMessageStream({ stream: result.stream }),
headers: {
"Content-Type": "application/octet-stream",
"Content-Encoding": "none",
},
})
}
The model string goes through the Vercel AI Gateway, so set AI_GATEWAY_API_KEY in a .env file. You can also use a provider package directly (@ai-sdk/anthropic, @ai-sdk/openai) with that provider's key. Either way, never prefix secrets with EXPO_PUBLIC_, because those values are bundled into the app.
4. Point the app at the API
// utils/api-url.ts
import Constants from "expo-constants"
export function generateAPIUrl(path: string) {
const normalized = path.startsWith("/") ? path : `/${path}`
if (process.env.NODE_ENV === "development") {
const origin = Constants.experienceUrl.replace("exp://", "http://")
return origin.concat(normalized)
}
const base = process.env.EXPO_PUBLIC_API_BASE_URL
if (!base) throw new Error("EXPO_PUBLIC_API_BASE_URL is not set")
return base.concat(normalized)
}
In development the app calls your local dev server. In production it calls your deployed origin.
5. Build the chat screen
// app/index.tsx
import { useState } from "react"
import { FlatList, KeyboardAvoidingView, Text, TextInput, View } from "react-native"
import { useChat } from "@ai-sdk/react"
import { DefaultChatTransport } from "ai"
import { fetch as expoFetch } from "expo/fetch"
import { generateAPIUrl } from "@/utils/api-url"
export default function Chat() {
const [input, setInput] = useState("")
const { messages, sendMessage, error } = useChat({
transport: new DefaultChatTransport({
fetch: expoFetch as unknown as typeof globalThis.fetch,
api: generateAPIUrl("/api/chat"),
}),
})
return (
<KeyboardAvoidingView behavior="padding" style={{ flex: 1 }}>
<FlatList
data={messages}
keyExtractor={(m) => m.id}
contentContainerStyle={{ padding: 16, gap: 12 }}
renderItem={({ item }) => (
<View style={{ alignSelf: item.role === "user" ? "flex-end" : "flex-start", maxWidth: "85%" }}>
{item.parts.map((part, i) =>
part.type === "text" ? <Text key={i}>{part.text}</Text> : null,
)}
</View>
)}
/>
{error && <Text style={{ color: "red", padding: 16 }}>{error.message}</Text>}
<TextInput
value={input}
onChangeText={setInput}
placeholder="Ask anything…"
onSubmitEditing={() => {
if (!input.trim()) return
sendMessage({ text: input })
setInput("")
}}
style={{ margin: 16, padding: 14, borderRadius: 16, borderWidth: 1, borderColor: "#e4e4e7" }}
/>
</KeyboardAvoidingView>
)
}
Run npx expo start and responses stream in token by token.
6. Add tool calling
Tools let the model call your code, for example to fetch an order status:
import { isStepCount, streamText, tool } from "ai"
import { z } from "zod"
const result = streamText({
model: "anthropic/claude-sonnet-5.5",
messages: await convertToModelMessages(messages),
stopWhen: isStepCount(5),
tools: {
orderStatus: tool({
description: "Get the status of an order by id",
inputSchema: z.object({ orderId: z.string() }),
execute: async ({ orderId }) => db.orders.status(orderId),
}),
},
})
On the client, tool results show up as message parts with a tool-orderStatus type, so you can render them as rich cards instead of text.
7. Production checklist
- Authenticate the route. Check a session token (see our Expo auth guide) so strangers can't spend your credits.
- Rate-limit per user and cap
maxOutputTokens. - Deploy the server, for example with EAS Hosting (
npx expo export --platform webtheneas deploy), and setEXPO_PUBLIC_API_BASE_URL. - Swap FlatList for FlashList v2 with
maintainVisibleContentPositionfor long chats. - Persist conversations in expo-sqlite or your backend.
- Consider on-device AI for short, private tasks. See on-device AI in React Native.
Polish the experience
Chat UIs feel much better with small touches: a light haptic when a response finishes, a bottom sheet for model settings, and smooth message animations with Reanimated 4. Browse our components, or start from the free Expo chat template to skip the UI groundwork.