Fetching from the OpenAI API directly isn't that hard. But the two or three seconds of silence between a user tapping "Send" and the first character appearing on screen can quietly destroy the feel of your app. Rolling your own streaming implementation means parsing Server-Sent Events, accumulating delta tokens, wiring error handling, and keeping your state in sync with the UI — none of which has anything to do with your actual product.
The Vercel AI SDK abstracts all of that away. It gives Rork (React Native / Expo) developers a type-safe, provider-agnostic way to add AI features in hours instead of days. In this guide, I'll walk through three practical patterns: streaming chat, tool calling, and structured outputs — all backed by real, copy-pasteable code.
Why Vercel AI SDK Over a Direct Integration
There are a few libraries that promise to simplify AI integration in React Native, but Vercel AI SDK stands out for three reasons.
End-to-end type safety. The SDK is TypeScript-first, and response types flow through your stack automatically. When you use generateObject(), the return value is fully inferred from your Zod schema — no manual casting, no as any.
Provider switching at zero cost. OpenAI, Anthropic, Google Gemini, Mistral — they all share the same interface. Switching models to compare cost and output quality means changing one import, not rewriting your backend.
Proven React Native compatibility. The useChat hook works in Expo and React Native with minor adjustments (more on this below). The backend side needs a Node.js or Edge Runtime environment, and Cloudflare Workers fits perfectly.
If you've previously tried building manual streaming from scratch — like the pattern described in our LLM Streaming Implementation Guide — the SDK will feel almost unfairly simple by comparison.
Backend Setup: Cloudflare Workers + Hono
The SDK handles the frontend, but you still need a backend to serve the streaming API. Here's the setup using Cloudflare Workers and Hono. For Hono configuration details, see the Hono × Cloudflare Workers REST API Guide.
# In your backend project
npm install ai @ai-sdk/openai hono zodThe streaming chat endpoint looks like this:
// src/index.ts (Cloudflare Worker + Hono)
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { streamText } from 'ai'
import { openai } from '@ai-sdk/openai'
const app = new Hono<{ Bindings: { OPENAI_API_KEY: string } }>()
app.use('/*', cors())
app.post('/api/chat', async (c) => {
const { messages } = await c.req.json()
// streamText handles all SSE delta sending automatically
const result = streamText({
model: openai('gpt-4o-mini'),
system: 'You are a helpful assistant.',
messages,
// Always cap tokens to manage costs
maxTokens: 1000,
})
// toDataStreamResponse() makes this compatible with the useChat hook
return result.toDataStreamResponse()
})
export default appWrapping streamText() output with toDataStreamResponse() produces a stream that useChat understands natively. All the low-level work — chunked SSE parsing, reconnect handling, error propagation — disappears behind that single method call.
Store your API key as a Cloudflare Workers secret, not in source code:
wrangler secret put OPENAI_API_KEYHardcoding keys in frontend or backend code risks GitHub Secret Scanning alerts and, more importantly, key exposure if your repository is ever public.
Streaming Chat in Your Rork App
On the Rork side, install the SDK:
npm install aiHere's a full chat screen using useChat:
// screens/ChatScreen.tsx
import { useChat } from 'ai/react'
import {
View, TextInput, FlatList, Text,
TouchableOpacity, StyleSheet, KeyboardAvoidingView, Platform
} from 'react-native'
const API_URL = 'https://your-worker.workers.dev/api/chat'
export default function ChatScreen() {
const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat({
api: API_URL,
onError: (error) => {
// Notify the user on error rather than silently retrying
console.error('Chat error:', error.message)
},
})
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<FlatList
data={messages}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View style={[
styles.bubble,
item.role === 'user' ? styles.userBubble : styles.aiBubble,
]}>
{/* Text updates in real time as tokens stream in */}
<Text style={styles.messageText}>{item.content}</Text>
</View>
)}
/>
<View style={styles.inputRow}>
<TextInput
style={styles.input}
value={input}
// React Native's onChangeText returns a plain string.
// useChat expects a web-style onChange event, so we shim it.
onChangeText={(text) =>
handleInputChange({ target: { value: text } } as any)
}
placeholder="Type a message..."
editable={\!isLoading}
multiline
/>
<TouchableOpacity
style={[styles.sendButton, isLoading && styles.disabled]}
onPress={() => handleSubmit()}
disabled={isLoading}
>
<Text style={styles.sendLabel}>{isLoading ? '…' : 'Send'}</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#fff' },
bubble: { margin: 8, padding: 12, borderRadius: 16, maxWidth: '80%' },
userBubble: { alignSelf: 'flex-end', backgroundColor: '#007AFF' },
aiBubble: { alignSelf: 'flex-start', backgroundColor: '#F2F2F7' },
messageText: { fontSize: 15, color: '#000' },
inputRow: {
flexDirection: 'row', padding: 8,
borderTopWidth: 1, borderColor: '#E5E5EA'
},
input: {
flex: 1, borderWidth: 1, borderColor: '#C7C7CC',
borderRadius: 20, paddingHorizontal: 16, paddingVertical: 8, marginRight: 8
},
sendButton: {
backgroundColor: '#007AFF', borderRadius: 20,
paddingHorizontal: 20, justifyContent: 'center'
},
disabled: { opacity: 0.5 },
sendLabel: { color: '#fff', fontWeight: '600' },
})One thing the official docs don't spell out: handleInputChange expects a web onChange event object, not a plain string. The shim { target: { value: text } } as any is the standard workaround when using useChat in React Native. It's a known friction point and an easy one to miss.
Everything else — message history, streaming token accumulation, loading state — is managed by the hook automatically.
Tool Calling: Letting AI Fetch Live Data
Tool calling lets the AI decide, at inference time, to reach out to an external API and incorporate that data into its answer. Instead of "Tokyo weather is whatever was in the training data," the AI calls your weather API and answers with current conditions.
Add tool definitions to your backend:
// Add to src/index.ts
import { streamText, tool } from 'ai'
import { z } from 'zod'
app.post('/api/chat-with-tools', async (c) => {
const { messages } = await c.req.json()
const result = streamText({
model: openai('gpt-4o'),
messages,
tools: {
getWeather: tool({
description: 'Fetch current weather for a given city',
// Zod schema prevents the model from passing wrong argument types
parameters: z.object({
city: z.string().describe('City name, e.g. Tokyo, London'),
}),
execute: async ({ city }) => {
// Replace with your real API call in production
// const res = await fetch(`https://api.openweathermap.org/...`)
return { city, temperature: 22, condition: 'Sunny', humidity: 55 }
},
}),
},
// Without maxSteps, the model stops after calling the tool
// and never delivers the final answer to the user
maxSteps: 3,
})
return result.toDataStreamResponse()
})The maxSteps setting is easy to overlook, and forgetting it produces a silent failure: the model calls the tool, receives the result, then stops — leaving the user with no response. Setting it to 3 allows: call tool → receive result → generate answer. Deeper multi-step reasoning is possible too; just increase the value.
For more advanced patterns including multiple parallel tools and error handling, see the AI Function Calling Guide.
Structured Outputs with generateObject
Not every AI feature is a chatbot. Sometimes you need the model to return a specific data shape — sentiment analysis, information extraction from free-text input, automatic content tagging. generateObject() is designed exactly for this.
// src/index.ts
import { generateObject } from 'ai'
app.post('/api/analyze-review', async (c) => {
const { reviewText } = await c.req.json()
const { object } = await generateObject({
model: openai('gpt-4o-mini'),
schema: z.object({
sentiment: z.enum(['positive', 'neutral', 'negative']),
score: z.number().min(1).max(5).describe('Rating from 1 to 5'),
summary: z.string().max(60).describe('One-sentence summary'),
keywords: z.array(z.string()).max(3).describe('Up to 3 key topics'),
}),
prompt: `Analyze this review:\n\n${reviewText}`,
})
// object is fully typed — object.sentiment is 'positive' | 'neutral' | 'negative'
// TypeScript catches any typo or wrong property access at compile time
return c.json(object)
})The return value is typed exactly from your Zod schema. When you consume it in your Rork app, autocomplete works, switch statements are exhaustive-checked, and accessing a nonexistent property is a compile error. Compare that to JSON.parse(response) as any — the difference in refactoring confidence alone is worth it.
The Vercel AI SDK earns its place in a Rork stack not because it's magic, but because it eliminates an entire category of accidental complexity. Start with useChat for conversational features, reach for tool calling when the AI needs live data, and use generateObject whenever you need structured output from unstructured input. Each pattern builds on the same foundation, so adding capabilities later is additive rather than a rewrite. Give it a try on your next Rork project.