AIExpoVercel AI SDKExpo RouterChatbot

Build an AI Chat App with Expo and the Vercel AI SDK (Streaming)

Step-by-step: build a streaming AI chatbot in Expo React Native with the Vercel AI SDK. Covers Expo Router API routes, useChat with expo/fetch, polyfills, tool calling, keeping API keys on the server, and deploying.

DeveloperMill4 min read

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-error on 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 web then eas deploy), and set EXPO_PUBLIC_API_BASE_URL.
  • Swap FlatList for FlashList v2 with maintainVisibleContentPosition for 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.

Frequently asked questions

How do I add ChatGPT or Claude to a React Native app?

Call the model from a server, never directly from the app. With Expo Router you can write an API route (app/api/chat+api.ts) that uses the Vercel AI SDK's streamText, then call it from the app with the useChat hook and expo/fetch for streaming. Your API key stays on the server.

Does streaming work in React Native?

Yes. Use `fetch` from `expo/fetch`, which supports streamed responses, and add polyfills for structuredClone, TextEncoderStream, and TextDecoderStream on native. The AI SDK's useChat hook then renders tokens as they arrive.

Is it safe to put an OpenAI or Anthropic API key in my Expo app?

No. Anything bundled in the app — including EXPO_PUBLIC_ environment variables — can be extracted. Keep provider keys in server-side environment variables used only by your API route, and add authentication and rate limiting to that route.

Where do Expo API routes run in production?

Expo Router API routes are server code. Deploy them with EAS Hosting or another server/serverless host, then set EXPO_PUBLIC_API_BASE_URL in the app so it calls your deployed origin instead of the dev server.

Build it faster with a template

Production-ready Expo React Native templates with the patterns from this post already wired up.

Browse templates