React OpenAI Integration: AI-Powered UI Features (2026)
The Vercel AI SDK is the standard way to integrate LLMs into React apps — it handles streaming responses, message state, loading indicators and tool calls with a single useChat hook. This guide covers streaming chat UI, function/tool calling, DALL-E image generation, embeddings for semantic search, and keeping your API key secure on the server.
Table of Contents
Setup and Security
npm install ai @ai-sdk/openai
# Vercel AI SDK — works with OpenAI, Anthropic, Google, Mistral and more
# .env.local (server-side only — never expose to browser)
OPENAI_API_KEY=sk-...
# NEVER put OPENAI_API_KEY in VITE_* or NEXT_PUBLIC_* variables.
# Always call OpenAI through a server route / Server Action —
# the API key must never appear in client-side JavaScript bundles.
Streaming Chat with useChat
// app/api/chat/route.ts — Next.js Route Handler
import { openai } from '@ai-sdk/openai'
import { streamText } from 'ai'
export const maxDuration = 30 // Allow streaming up to 30s
export async function POST(req: Request) {
const { messages } = await req.json()
const result = await streamText({
model: openai('gpt-4o'),
system: 'You are a helpful React expert. Answer concisely with code examples.',
messages,
maxTokens: 1024,
temperature: 0.7,
})
return result.toDataStreamResponse()
}
// Client — useChat hook from Vercel AI SDK
'use client'
import { useChat } from 'ai/react'
import { useRef, useEffect } from 'react'
export function ChatUI() {
const { messages, input, handleInputChange, handleSubmit, isLoading, error, stop } = useChat({
api: '/api/chat',
initialMessages: [
{ id: 'sys', role: 'assistant', content: 'Hi! I\'m your React assistant. Ask me anything.' }
],
onError: (err) => console.error('Chat error:', err),
})
const bottomRef = useRef<HTMLDivElement>(null)
useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages])
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '70vh' }}>
{/* Messages */}
<div style={{ flex: 1, overflowY: 'auto', padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>
{messages.map(msg => (
<div key={msg.id} style={{
alignSelf: msg.role === 'user' ? 'flex-end' : 'flex-start',
maxWidth: '80%',
background: msg.role === 'user' ? 'linear-gradient(135deg,#6366f1,#22d3ee)' : '#0d1424',
color: '#e2e8f0', borderRadius: 12, padding: '10px 14px',
border: msg.role === 'assistant' ? '1px solid rgba(99,102,241,.2)' : 'none',
}}>
{/* msg.content streams in token by token while isLoading */}
{msg.content}
</div>
))}
{isLoading && (
<div style={{ alignSelf: 'flex-start', color: '#64748b', fontSize: 13 }}>
Thinking...
</div>
)}
{error && <p role="alert" style={{ color: '#ef4444' }}>{error.message}</p>}
<div ref={bottomRef} />
</div>
{/* Input */}
<form onSubmit={handleSubmit} style={{ display: 'flex', gap: 8, padding: 16, borderTop: '1px solid rgba(99,102,241,.2)' }}>
<input
value={input}
onChange={handleInputChange}
placeholder="Ask about React..."
disabled={isLoading}
style={{ flex: 1, background: '#111827', border: '1px solid rgba(99,102,241,.3)', borderRadius: 8, padding: '10px 14px', color: '#e2e8f0', outline: 'none' }}
/>
{isLoading
? <button type="button" onClick={stop}>Stop</button>
: <button type="submit" disabled={!input.trim()}>Send</button>
}
</form>
</div>
)
}
Tool Calling
// Server — define tools the model can call
import { openai } from '@ai-sdk/openai'
import { streamText, tool } from 'ai'
import { z } from 'zod'
export async function POST(req: Request) {
const { messages } = await req.json()
const result = await streamText({
model: openai('gpt-4o'),
messages,
tools: {
getWeather: tool({
description: 'Get the current weather for a city',
parameters: z.object({
city: z.string().describe('The city name'),
unit: z.enum(['celsius', 'fahrenheit']).default('celsius'),
}),
execute: async ({ city, unit }) => {
// Call real weather API
const weather = await weatherApi.fetch(city, unit)
return { city, temperature: weather.temp, condition: weather.condition }
},
}),
searchProducts: tool({
description: 'Search the product catalog',
parameters: z.object({ query: z.string(), limit: z.number().default(5) }),
execute: async ({ query, limit }) => {
return await db.product.findMany({ where: { name: { contains: query } }, take: limit })
},
}),
},
maxSteps: 5, // Allow multi-step tool use
})
return result.toDataStreamResponse()
}
// Client — useChat handles tool call/result display automatically
// Tool results stream back as part of the assistant message
Text Completion with useCompletion
// For single-turn completions (not chat) — email drafts, summaries, translations
import { useCompletion } from 'ai/react'
function EmailDrafter() {
const { completion, input, handleInputChange, handleSubmit, isLoading } = useCompletion({
api: '/api/complete',
})
return (
<div>
<form onSubmit={handleSubmit}>
<textarea
value={input}
onChange={handleInputChange}
placeholder="Describe the email you need to write..."
rows={3}
/>
<button type="submit" disabled={isLoading}>
{isLoading ? 'Writing...' : 'Draft Email'}
</button>
</form>
{completion && (
<div style={{ marginTop: 16, whiteSpace: 'pre-wrap' }}>{completion}</div>
)}
</div>
)
}
// app/api/complete/route.ts
import { streamText } from 'ai'
import { openai } from '@ai-sdk/openai'
export async function POST(req: Request) {
const { prompt } = await req.json()
const result = await streamText({
model: openai('gpt-4o-mini'),
prompt: `Write a professional email: ${prompt}`,
})
return result.toDataStreamResponse()
}
DALL-E Image Generation
// app/api/generate-image/route.ts
import OpenAI from 'openai'
const openai = new OpenAI()
export async function POST(req: Request) {
const { prompt } = await req.json()
const response = await openai.images.generate({
model: 'dall-e-3',
prompt,
n: 1,
size: '1024x1024',
quality: 'standard',
style: 'vivid', // 'vivid' | 'natural'
})
return Response.json({ url: response.data[0].url })
}
// Client component
function ImageGenerator() {
const [prompt, setPrompt] = useState('')
const [imageUrl, setImageUrl] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
async function generate() {
setLoading(true)
const { url } = await fetch('/api/generate-image', {
method: 'POST',
body: JSON.stringify({ prompt }),
headers: { 'Content-Type': 'application/json' },
}).then(r => r.json())
setImageUrl(url)
setLoading(false)
}
return (
<div>
<input value={prompt} onChange={e => setPrompt(e.target.value)} placeholder="Describe an image..." />
<button onClick={generate} disabled={loading || !prompt}>
{loading ? 'Generating...' : 'Generate'}
</button>
{imageUrl && <img src={imageUrl} alt={prompt} style={{ maxWidth: '100%', borderRadius: 12, marginTop: 16 }} />}
</div>
)
}
Embeddings and Semantic Search
// Generate embeddings — convert text to a vector for similarity search
import { embed, embedMany } from 'ai'
import { openai } from '@ai-sdk/openai'
// Embed a single query
const { embedding } = await embed({
model: openai.embedding('text-embedding-3-small'),
value: 'How do I use useEffect?',
})
// embedding is a number[] (1536 dimensions for text-embedding-3-small)
// Embed many documents (e.g., to build a knowledge base)
const { embeddings } = await embedMany({
model: openai.embedding('text-embedding-3-small'),
values: documents.map(d => d.content),
})
// Cosine similarity — find most similar document
function cosineSimilarity(a: number[], b: number[]): number {
const dot = a.reduce((sum, ai, i) => sum + ai * b[i], 0)
const magA = Math.sqrt(a.reduce((sum, ai) => sum + ai * ai, 0))
const magB = Math.sqrt(b.reduce((sum, bi) => sum + bi * bi, 0))
return dot / (magA * magB)
}
// In practice: store embeddings in pgvector, Pinecone, or Qdrant
// and use their native similarity search (much faster than JS loop)
RAG Pattern
// RAG (Retrieval-Augmented Generation) — ground answers in your own documents
// app/api/chat/route.ts with RAG
export async function POST(req: Request) {
const { messages } = await req.json()
const lastMessage = messages[messages.length - 1].content
// 1. Embed the user's question
const { embedding } = await embed({
model: openai.embedding('text-embedding-3-small'),
value: lastMessage,
})
// 2. Find similar documents in your vector store
const relevantDocs = await vectorStore.similaritySearch(embedding, { topK: 5 })
// 3. Inject retrieved context into the system prompt
const context = relevantDocs.map(d => d.content).join('\n\n---\n\n')
const result = await streamText({
model: openai('gpt-4o'),
system: `You are a helpful assistant. Answer based ONLY on this context:
${context}
If the answer is not in the context, say "I don't have information about that."`,
messages,
})
return result.toDataStreamResponse()
}
Cost control: Use
gpt-4o-mini for simple tasks (classification, summaries, chat) and gpt-4o only when you need complex reasoning. Set maxTokens on every call. Add rate limiting per user (Redis + sliding window) before going to production to prevent runaway costs.