Skip to content
AI Beginner Tutorial

Build a Streaming Chat UI with React, Vercel AI SDK, and FastAPI

Wire up token-by-token streaming between a FastAPI backend and a React frontend using the Vercel AI SDK data-stream protocol.

Priya Nair
Priya Nair
AI & Developer Experience Writer · Jun 30, 2026 · 5 min read
Build a Streaming Chat UI with React, Vercel AI SDK, and FastAPI

What you'll build

A chat interface where the model's response streams token-by-token into the UI, conversation history threads automatically across turns, and tool invocations render inline. The React side uses Vercel AI SDK's useChat hook; the backend is a FastAPI endpoint that emits the AI SDK data-stream protocol.

Prerequisites

  • Node.js 18+ and npm 9+
  • Python 3.11+
  • An OpenAI API key exported as OPENAI_API_KEY in your shell
  • Basic familiarity with React hooks and Python async/await

1. Set up the React project

npm create vite@latest chat-ui -- --template react
cd chat-ui
npm install
npm install ai

The ai package is the full Vercel AI SDK. useChat lives at the ai/react entry point; no extra React adapter package needed for React 18+.

2. Build the FastAPI backend

Create a sibling directory, wire up a virtual environment, and install dependencies:

mkdir chat-api && cd chat-api
python -m venv .venv
source .venv/bin/activate   # Windows: .venv\Scripts\activate
pip install fastapi "uvicorn[standard]" openai

Create main.py:

import json
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from openai import AsyncOpenAI
from pydantic import BaseModel

client = AsyncOpenAI()  # reads OPENAI_API_KEY from the environment
app = FastAPI()

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:5173"],
    allow_methods=["POST"],
    allow_headers=["*"],
    expose_headers=["x-vercel-ai-data-stream"],
)

class Message(BaseModel):
    role: str
    content: str

class ChatRequest(BaseModel):
    messages: list[Message]

async def generate(messages: list[Message]):
    stream = await client.chat.completions.create(
        model="gpt-4o-mini",
        stream=True,
        messages=[{"role": m.role, "content": m.content} for m in messages],
    )
    async for chunk in stream:
        if chunk.choices:
            content = chunk.choices[0].delta.content
            if content:
                # AI SDK data-stream protocol: type code 0 = text chunk
                yield f"0:{json.dumps(content)}\n"
    # type code d = finish message, required by the SDK
    yield 'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'

@app.post("/api/chat")
async def chat(req: ChatRequest):
    return StreamingResponse(
        generate(req.messages),
        media_type="text/plain; charset=utf-8",
        headers={"x-vercel-ai-data-stream": "v1"},
    )

Three things matter here: the 0:{json_string}\n line format tells useChat these are text tokens, the x-vercel-ai-data-stream: v1 response header tells the SDK which protocol to parse, and expose_headers makes that header readable to the browser across origins. Miss any one of them and the hook won't stream correctly. The if chunk.choices: guard is also necessary because OpenAI occasionally emits metadata-only chunks where choices is empty, and indexing into an empty list raises an IndexError.

Start the server:

uvicorn main:app --reload --port 8000

3. Build the Chat component

Replace src/App.jsx:

import { useChat } from 'ai/react'

export default function App() {
  const { messages, input, handleInputChange, handleSubmit, isLoading, error } =
    useChat({ api: 'http://localhost:8000/api/chat' })

  return (
    <div style={{ maxWidth: 640, margin: '2rem auto', fontFamily: 'sans-serif' }}>
      <div style={{ minHeight: 300, borderBottom: '1px solid #eee', paddingBottom: 16 }}>
        {messages.map((m) => (
          <div key={m.id} style={{ marginBottom: 12 }}>
            <strong>{m.role === 'user' ? 'You' : 'AI'}:</strong>
            <span style={{ marginLeft: 8 }}>{m.content}</span>

            {m.toolInvocations?.map((t) => (
              <div key={t.toolCallId} style={{ marginLeft: 24, color: '#888', fontSize: 12 }}>
                Tool: {t.toolName}
                {t.state === 'result' && <span> → {JSON.stringify(t.result)}</span>}
              </div>
            ))}
          </div>
        ))}
        {isLoading && <p style={{ color: '#999' }}>Thinking...</p>}
        {error && <p style={{ color: 'red' }}>{error.message}</p>}
      </div>

      <form onSubmit={handleSubmit} style={{ display: 'flex', gap: 8, marginTop: 16 }}>
        <input
          value={input}
          onChange={handleInputChange}
          placeholder="Say something..."
          style={{ flex: 1, padding: 8 }}
        />
        <button type="submit" disabled={isLoading}>Send</button>
      </form>
    </div>
  )
}

Conversation history is automatic. On every submit, useChat POSTs the full messages array to your backend, so the model always has context. The m.toolInvocations array is populated by the SDK when your backend streams tool-call protocol lines; the component renders them without extra state management.

Start the dev server:

cd ../chat-ui && npm run dev

Verify it works

Open http://localhost:5173. Send a message and watch the response appear word-by-word. Then send a follow-up that refers to the first reply ("What did you just say?") to confirm history is threading. In the uvicorn terminal you should see:

INFO:     127.0.0.1:XXXXX - "POST /api/chat HTTP/1.1" 200 OK

Troubleshooting

CORS error in the browser console. The allow_origins list must exactly match the Vite URL including the port. Vite prints the actual URL at startup; if it's not 5173, update main.py and restart uvicorn.

Response never renders despite a 200. Open the Network tab and confirm the response carries both x-vercel-ai-data-stream: v1 and that the header is visible to the browser (not blocked by CORS). Also verify each yielded line is 0:"token"\n with the value as a valid JSON string. json.dumps(content) handles escaping correctly; raw f-string concatenation will break on quotes and newlines in the model output.

ModuleNotFoundError for openai or fastapi. Your virtual environment isn't active. Run source .venv/bin/activate before uvicorn.

useChat sends requests but isLoading never clears. The finish line d:{...}\n is missing from the stream. The SDK waits for it to mark the response complete.

Next steps

  • Tool calling: Define tools in the OpenAI create() call and stream tool-call parts using the AI SDK protocol codes (9: for tool call, a: for tool result). The m.toolInvocations renderer is already in place.
  • Production auth: Add a Depends guard in FastAPI to validate a bearer token before the endpoint is reachable.
  • Switch to a Vercel Edge Function: The AI SDK's streamText helper from ai (Node.js) produces the data-stream format for you, so you skip the manual line formatting entirely.
  • Full protocol spec and provider docs: sdk.vercel.ai/docs
Priya Nair
Written by
Priya Nair · AI & Developer Experience Writer

Priya covers AI frameworks, developer productivity tooling, and the startup ecosystem across South and Southeast Asia, bringing a researcher's rigour and a practitioner's empathy to every story. She is deeply sceptical of benchmarks and asks hard questions so her readers don't have to.

Discussion 0

Join the discussion

Sign in or create an account to comment and vote.

No comments yet

Be the first to weigh in.

Related Reading