This vercel ai sdk usechat streaming chat ui tutorial walks through building a token-streaming chat interface in Next.js using the useChat hook and a server route that streams from an LLM. You’ll get runnable code for both ends and see exactly what the UI does as tokens arrive.
Prerequisites
- Node.js 18+ (Edge runtime works fine on Node 18+ for the route)
- A Next.js 14+ project using the App Router
- An API key from an OpenAI-compatible provider, or a gateway that exposes one endpoint
- Basic familiarity with React client components
If you don’t have a Next.js app yet, scaffold one:
npx create-next-app@latest chat-app --app --ts --no-tailwind
cd chat-app
Install the SDK packages:
npm i ai @ai-sdk/react @ai-sdk/openai
Server: the streaming route
The server side is a single POST handler. It receives the message array from the client, calls streamText, and returns a data stream response that useChat understands natively.
Create app/api/chat/route.ts:
import { openai } from '@ai-sdk/openai';
import { streamText } from 'ai';
// Use edge if you want lower cold starts; node works too.
export const runtime = 'edge';
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai('gpt-4o-mini'),
messages,
});
return result.toDataStreamResponse();
}
If you’d rather not juggle multiple provider keys, point the provider at an OpenAI-compatible gateway. For example, n4n.ai exposes one endpoint covering 240+ models with automatic fallback on provider degradation; just swap the client:
import { createOpenAI } from '@ai-sdk/openai';
const openai = createOpenAI({
baseURL: 'https://api.n4n.ai/v1',
apiKey: process.env.N4N_API_KEY,
});
The rest of the code stays identical. useChat neither knows nor cares which backend produced the stream.
Client: the useChat hook
Create a client component at app/components/chat.tsx. The hook manages message state, input binding, submission, and streaming reconciliation.
'use client';
import { useChat } from '@ai-sdk/react';
export function Chat() {
const {
messages,
input,
handleInputChange,
handleSubmit,
isLoading,
stop,
error,
} = useChat();
return (
<div style={{ maxWidth: 700, margin: '0 auto' }}>
{error && (
<div style={{ color: 'red', marginBottom: 8 }}>
Error: {error.message}
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{messages.map((m) => (
<div key={m.id} style={{ whiteSpace: 'pre-wrap' }}>
<strong>{m.role}:</strong> {m.content}
</div>
))}
</div>
<form
onSubmit={handleSubmit}
style={{ marginTop: 16, display: 'flex', gap: 8 }}
>
<input
value={input}
onChange={handleInputChange}
placeholder="Type a message…"
style={{ flex: 1, padding: 8 }}
/>
<button type="submit" disabled={isLoading}>
Send
</button>
<button type="button" onClick={stop} disabled={!isLoading}>
Stop
</button>
</form>
</div>
);
}
Wire it into a page
Edit app/page.tsx:
import { Chat } from './components/chat';
export default function Page() {
return (
<main style={{ padding: 24 }}>
<h1>Streaming Chat</h1>
<Chat />
</main>
);
}
Checkpoint: first stream
Run npm run dev and open http://localhost:3000. Type “Explain recursion in one sentence” and hit Send.
Expected behavior:
- The input clears immediately.
- A new
usermessage appears. - An
assistantmessage appears with empty content, then fills in word-by-word as the model streams. - The Send button disables while
isLoadingis true; Stop aborts the stream.
You should see something like:
user: Explain recursion in one sentence
assistant: A function that calls itself to solve smaller instances of the same problem…
The tokens arrive incrementally because toDataStreamResponse() emits Server-Sent Events that useChat parses and appends to m.content.
Why useChat beats a hand-rolled fetch
You could stream with fetch + ReadableStream manually, but you’d reimplement:
- Message ID generation and deduplication
- Partial token buffering and React state batching
- Abort controller wiring for Stop
- Error normalization
useChat does this with documented semantics. In this vercel ai sdk usechat streaming chat ui tutorial we lean on that so the client stays 30 lines.
One opinion: keep the route thin. Don’t transform messages server-side unless you’re doing RAG or guardrails. The hook sends a standard {role, content}[] shape that streamText accepts directly.
Handling provider errors
If the model 500s or the key is invalid, useChat surfaces an error object. Add a retry by calling regenerate (available from the hook) or just resubmitting. For production, wrap streamText in try/catch and return a clean status:
export async function POST(req: Request) {
try {
const { messages } = await req.json();
const result = streamText({ model: openai('gpt-4o-mini'), messages });
return result.toDataStreamResponse();
} catch (e) {
return new Response('Model unavailable', { status: 502 });
}
}
The client error will then show “Model unavailable” via error.message only if you map statuses; otherwise it shows a generic stream error. Keep your error contract explicit.
Adding cache-control hints
If your gateway or provider supports prompt caching, forward the hint. With the AI SDK you pass providerOptions:
const result = streamText({
model: openai('gpt-4o-mini'),
messages,
providerOptions: { openai: { cacheControl: true } },
});
n4n.ai honors client routing directives and forwards provider cache-control hints, so the same option works without client changes if you use that endpoint.
UX touches that matter
Streaming UIs feel broken without feedback. Add:
- A blinking cursor or “thinking” state when
isLoadingand no assistant content yet. scrollIntoViewon the message list bottom via arefanduseEffect.- Disable input while loading to prevent queueing duplicates.
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
Place <div ref={bottomRef} /> after the map.
Production considerations
- Never expose your provider key to the client. The route holds it via
process.env. - Use
export const runtime = 'edge'only if your provider latency benefits; otherwise node runtime is simpler for debugging. - For multi-model UIs, pass
body: { model }touseChatand read it in the route to selectopenai(reqBody.model). - Meter usage if you bill customers. The stream response doesn’t expose tokens directly; use
onFinishinstreamTextto logusage:
const result = streamText({
model: openai('gpt-4o-mini'),
messages,
onFinish: ({ usage }) => {
console.log('tokens', usage);
},
});
That’s the full loop. This vercel ai sdk usechat streaming chat ui tutorial gave you a runnable Next.js chat with real token streaming, stop support, and a clean separation between UI state and model streaming. Swap the model string or the base URL and the frontend doesn’t change.