
The Vercel AI SDK for Frontend Engineers: streamText, useChat and Providers
By Ghazi Khan | Nov 9, 2026 - 9 min read
You have probably copied a streamText route and a useChat component from a tutorial, and it worked. Then the real requirements arrive: keep chats after a refresh, switch models without touching ten files, and stop mixing up UIMessage and ModelMessage. This post walks through the Vercel AI SDK end to end, so you know what each piece does and where your own code belongs.
Earlier posts in this series used the SDK as a tool. This one is about the SDK itself. Everything below uses AI SDK 7, which renamed several APIs that most tutorials still show.
How the Vercel AI SDK fits together
The SDK has three layers. If you have built a React app on top of an API, you already know this shape.
| Layer | Package | Main APIs | Think of it as |
|---|---|---|---|
| Providers | @ai-sdk/openai, @ai-sdk/anthropic, the AI Gateway | openai('...'), gateway('...'), customProvider | A database driver behind an ORM |
| Core | ai | generateText, streamText | A server function that returns a promise or stream |
| UI | @ai-sdk/react plus helpers in ai | useChat, DefaultChatTransport, toUIMessageStream | React Query, scoped to one conversation |
Providers hide the differences between model vendors. Core code calls streamText({ model }) and does not care whether model is OpenAI, Anthropic or something routed through the Vercel AI Gateway. In AI SDK 7 the gateway is the default global provider, so a plain string like 'anthropic/claude-sonnet-5.5' works once AI_GATEWAY_API_KEY is set.
Core is the server side. streamText starts a model call and returns a result object with streams (textStream, stream) and promises (text, usage) on it. It runs in Node, a Route Handler or a plain script.
UI is the client side. useChat holds the conversation state, sends requests through a transport and turns the streamed response into React state.
The piece that confuses people most is that there are two message types:
| Type | Lives in | Shape | Analogy |
|---|---|---|---|
UIMessage | useChat, your database | id, role, parts (text, tool calls, sources), metadata | Your view model |
ModelMessage | The request to the model | What the provider API expects, no UI-only data | The DTO you send to an API |
convertToModelMessages is the mapper between them. Store and render UIMessage. Convert to ModelMessage right before calling the model.
Here is one request, end to end:
Diagram100%flowchart LR A["useChat: sendMessage"] --> B["DefaultChatTransport: POST /api/chat"] B --> C["Route Handler: load history, validateUIMessages"] C --> D["convertToModelMessages"] D --> E["streamText with a provider model"] E --> F["toUIMessageStream"] F --> G["createUIMessageStreamResponse"] G --> H["useChat: messages and parts update"]visualized by
Build it: a Next.js chat with server-side history
Install the packages. AI SDK 7 needs Node.js 22 or later and is ESM-only.
npm i ai @ai-sdk/react
Step 1: one file that knows model ids
Model ids change often. Keep them in one place and give them names that describe the job, not the vendor.
// lib/models.ts
import { customProvider, gateway } from 'ai';
export const models = customProvider({
languageModels: {
fast: gateway('anthropic/claude-haiku-5.5'),
smart: gateway('anthropic/claude-sonnet-5.5'),
},
fallbackProvider: gateway,
});
The rest of the app calls models.languageModel('smart'). To move smart to another vendor, change one line. If you prefer calling a vendor directly instead of the gateway, install its package and swap the value, for example openai('gpt-5-mini') from @ai-sdk/openai with OPENAI_API_KEY set.
Step 2: try it from Node first
Before any React, check that the model call works from a script. It is the fastest feedback loop you will get.
// scripts/try-model.ts
import { streamText } from 'ai';
import { models } from '../lib/models';
const result = streamText({
model: models.languageModel('fast'),
instructions: 'Answer in two sentences.',
prompt: 'What does a useEffect cleanup function do?',
});
for await (const chunk of result.textStream) {
process.stdout.write(chunk);
}
console.log('\n', await result.usage);
Run it with node --env-file=.env.local --import tsx scripts/try-model.ts (install tsx as a dev dependency). Note instructions: in AI SDK 7 it replaces system.
Step 3: a chat store
The server owns history. This file store is enough for local development. In production you swap it for Postgres or Redis, and the function signatures stay the same.
// lib/chat-store.ts
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import type { UIMessage } from 'ai';
const dir = path.join(process.cwd(), '.chats');
function fileFor(id: string) {
if (!/^[\w-]+$/.test(id)) throw new Error('Invalid chat id');
return path.join(dir, `${id}.json`);
}
export async function loadChat(id: string): Promise<UIMessage[]> {
try {
return JSON.parse(await readFile(fileFor(id), 'utf8'));
} catch {
return []; // a new chat has no file yet
}
}
export async function saveChat(id: string, messages: UIMessage[]) {
await mkdir(dir, { recursive: true });
await writeFile(fileFor(id), JSON.stringify(messages, null, 2));
}
The id check matters: the id comes from the request, and without it someone could send ../ and write outside the folder.
Step 4: the route handler
The client sends only its newest message. The server loads the rest, validates it, calls the model and saves when the stream ends.
// app/api/chat/route.ts
import {
convertToModelMessages,
createIdGenerator,
createUIMessageStreamResponse,
streamText,
toUIMessageStream,
validateUIMessages,
type UIMessage,
} from 'ai';
import { models } from '@/lib/models';
import { loadChat, saveChat } from '@/lib/chat-store';
export const maxDuration = 30;
export async function POST(req: Request) {
const { id, message }: { id: string; message: UIMessage } = await req.json();
const messages = await validateUIMessages({
messages: [...(await loadChat(id)), message],
});
const result = streamText({
model: models.languageModel('smart'),
instructions: 'You are a concise assistant for frontend engineers.',
messages: await convertToModelMessages(messages),
});
result.consumeStream(); // no await: keeps generating if the tab closes
return createUIMessageStreamResponse({
stream: toUIMessageStream({
stream: result.stream,
originalMessages: messages,
generateMessageId: createIdGenerator({ prefix: 'msg', size: 16 }),
onEnd: ({ messages }) => saveChat(id, messages),
}),
});
}
Three details are doing real work here. validateUIMessages checks that stored and incoming messages still match the expected shape, which protects you when your schema changes. originalMessages tells the UI stream which history to append to, so onEnd receives the full list including the new assistant reply. consumeStream() without await lets the model finish even if the client disconnects, so the answer still gets saved. The trade-off is that a closed tab no longer cancels the model call. The Node streaming post covers the opposite choice, cancelling with abortSignal.
Diagram100%sequenceDiagram participant UI as useChat participant R as Route Handler participant DB as Chat store participant M as Model UI->>R: POST { id, message } R->>DB: loadChat(id) R->>M: streamText(messages) M-->>R: stream parts R-->>UI: UI message stream R->>DB: onEnd: saveChat(id, messages)visualized by
Step 5: the page and the client component
The page is a Server Component that loads saved history and passes it down.
// app/chat/[id]/page.tsx
import { loadChat } from '@/lib/chat-store';
import { Chat } from './chat';
export default async function Page({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return <Chat id={id} initialMessages={await loadChat(id)} />;
}
// app/chat/[id]/chat.tsx
'use client';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport, type UIMessage } from 'ai';
import { useState } from 'react';
const transport = new DefaultChatTransport({
prepareSendMessagesRequest: ({ id, messages }) => ({
body: { id, message: messages.at(-1) }, // server owns history
}),
});
type Props = { id: string; initialMessages: UIMessage[] };
export function Chat({ id, initialMessages }: Props) {
const { messages, sendMessage, status, stop, error } = useChat({
id,
messages: initialMessages,
transport,
throttle: 50,
});
const [input, setInput] = useState('');
const busy = status === 'submitted' || status === 'streaming';
return (
<main>
{messages.map((m) => (
<article key={m.id} aria-label={m.role === 'user' ? 'You' : 'AI'}>
{m.parts.map((p, i) =>
p.type === 'text' ? <p key={i}>{p.text}</p> : null,
)}
</article>
))}
{error && <p role='alert'>Something went wrong. Please try again.</p>}
<form
onSubmit={(e) => {
e.preventDefault();
if (!input.trim() || busy) return;
sendMessage({ text: input });
setInput('');
}}>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
aria-label='Message'
/>
{busy ? (
<button type='button' onClick={stop}>
Stop
</button>
) : (
<button type='submit'>Send</button>
)}
</form>
</main>
);
}
Open /chat/test-1, send a message, refresh, and the conversation is still there. status moves through submitted, streaming and ready (or error), which is all you need for loading and stop states. For scroll and screen reader behaviour on top of this, see last week's streaming chat UI post.
Common mistakes and their fixes
- Passing
UIMessage[]straight to the model. UI messages carry ids, parts and metadata the provider does not understand. Fix:await convertToModelMessages(messages)right beforestreamText. - Following a v5 or v6 tutorial line by line. The old names still work in AI SDK 7 but are deprecated and log warnings. Fix: use the current names.
| Older code | AI SDK 7 |
|---|---|
system: '...' | instructions: '...' |
result.toUIMessageStreamResponse() | createUIMessageStreamResponse({ stream: toUIMessageStream(...) }) |
result.fullStream | result.stream |
onFinish | onEnd |
stepCountIs(n) | isStepCount(n) |
- Saving chats from the client. The
onFinishcallback inuseChatnever runs if the user closes the tab mid-answer. Fix: save on the server inonEnd, and callconsumeStream()so the stream completes. - Trusting history sent by the browser. Anyone can edit a request body and rewrite earlier turns. Fix: send only the newest message, load the rest from your store, and run
validateUIMessages. - Model ids scattered across files. A model upgrade becomes a find-and-replace across the codebase. Fix: one
customProviderwith job-based aliases likefastandsmart.
Interview angle
AI SDK questions in frontend interviews rarely ask you to recite an API. They ask you to design a chat: where history lives, how streaming reaches the UI, and how you would switch model vendors. Explaining the provider, Core and UI layers, the UIMessage to ModelMessage conversion and server-side persistence with onEnd covers most follow-ups. Practise with the AI SDK interview questions.
What's next
On Thursday, the case study How AI Writes Inside a Notion-Style Editor uses the same streamText building blocks to stream tool calls into a rich text editor. Next week we look at structured output: getting reliable, typed JSON from a model with Zod.
Conclusion
The Vercel AI SDK is three layers with clear jobs. Providers decide which model runs, Core runs it and streams the result, and UI turns that stream into React state. Keep model ids behind aliases, store UIMessage and convert at the edge, and let the server own and save history. With those three habits, the snippets you copied become an architecture you can explain and extend.
Advertisement
Ready to practice?
Test your skills with our interactive UI challenges and build your portfolio.
Start Coding Challenge