AI & MCP
Streaming UI with the AI SDK: route handler, useChat and parts
Streaming with the AI SDK is a route handler returning parts and a component that reads them: text appears as it arrives, a tool call as its own part.
Key points
- The model and the key stay in the route handler; the browser receives only a stream of parts.
- useChat exposes messages and sendMessage: every message has an ordered parts array.
- A tool call is a part named tool-
, not text to interpret.
Streaming with the AI SDK is a route handler returning a stream of parts and a component that reads message.parts: text appears as it arrives, and a tool call appears as its own part. You do not handle the protocol by hand.
Three files, one path
app/api/chat/route.ts: receives messages, calls the model, returns the stream.app/page.tsx: usesuseChatand renders the parts of every message..env.local: holds the provider key, which never leaves the server.
import {
streamText,
convertToModelMessages,
createUIMessageStreamResponse,
toUIMessageStream,
type UIMessage,
} from 'ai';
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: 'anthropic/claude-sonnet-5.5',
messages: await convertToModelMessages(messages),
});
return createUIMessageStreamResponse({
stream: toUIMessageStream({ stream: result.stream }),
});
}
The route handler does not wait for the full answer: it returns a stream object and the framework sends it to the browser while the model writes. convertToModelMessages strips the UI fields the model should not see, such as ids and timestamps.
The key stays on the server
The model is chosen in the route handler, not in the component. A key in a NEXT_PUBLIC_ variable lands in the bundle and anyone can read it from the browser. If the model must change per user or per plan, decide on the server after reading the session: the client sends messages, not a model name.
The parts are the interface
'use client';
import { useChat } from '@ai-sdk/react';
import { useState } from 'react';
export default function Chat() {
const [input, setInput] = useState('');
const { messages, sendMessage } = useChat();
return (
{messages.map((m) =>
m.parts.map((part, i) => {
if (part.type === 'text') return {part.text};
if (part.type === 'tool-search_components')
return {JSON.stringify(part, null, 2)};
return null;
}),
)}
{
e.preventDefault();
sendMessage({ text: input });
setInput('');
}}
>
setInput(e.target.value)} />
);
}
message.parts is an ordered array: it holds text, reasoning and tool calls in the order the model produced them. A tool call is named tool- plus the name you gave the tool, so tool-search_components. There is no HTML to interpret and no format to guess.
Multi-step: raise stopWhen
With the default isStepCount(1) generation stops at the first tool call: the result runs, but the model does not use it to answer. For an agent that searches, reads and then writes, set stopWhen: isStepCount(5) with an explicit ceiling. The number is a product choice: every extra step costs a whole prompt.
What streaming does not do
- It does not hide latency: the first token arrives when it arrives; streaming changes the wait, not the time.
- It does not secure the route handler: authentication, rate limits and quota are still yours to write.
- It does not validate tool output: if the server returns text with instructions, the client renders it.
The limitation
The ai package at 7.0.159 takes 8.4 MB unpacked across 714 files, requires Node 22 and zod as a peer dependency: it is a heavy dependency, not a two-line hook. Major 7 changed the API from earlier versions, so an old tutorial produces type errors instead of a UI: pin the version in the lockfile and read the guide for the major you use.
Where to continue
Server-side streaming without the SDK is in streaming and Suspense in Next.js, the difference between a route handler and a server action in route handler or server action, suspended rendering in Suspense in React. Ready-made chat parts are in 111,985 components and 1,556 blocks; an example with a declared upstream licence is Jahed chatgpt prompt input.
Frequently asked questions
Do I need Next.js for streaming with the AI SDK?
No. You need an endpoint that returns a stream and a client that reads it; the official guide uses Next.js App Router because it is the shortest path. On Vite, Express or another framework you change the transport, not the data model.
Where do I put the provider key?
In an environment variable read only by the server, such as AI_GATEWAY_API_KEY in .env.local. A key with a public prefix lands in the bundle and anyone can read it from the browser.
How do I render a tool call instead of text?
Read message.parts and handle the part whose type is tool- plus the tool name, for example tool-search_components. The part carries input and result, so you can render it as JSON without parsing strings.
Why does the model not answer after the tool call?
Because stopWhen defaults to isStepCount(1): generation ends at the first call. Raise the limit, to 5 for example, if you want the model to use the tool result and close the answer.
Does streaming reduce tokens or cost?
No. The tokens are the same, only the moment the user sees them changes. Cost drops by cutting context and steps, not by changing how text reaches the browser.