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.

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.