guides
Use with the AI SDK
Wire the components to the Vercel AI SDK: streaming text, reasoning and tool calls.
setup
This guide uses the Vercel AI SDK with Next.js. It was written against ai 7 and @ai-sdk/react 4; check the SDK docs if you are on another major version, since the server helpers have changed between releases. Do the getting started steps first, then add the components used below.
pnpm add ai @ai-sdk/react zod
npx shadcn@latest add \
https://turnui.xyz/r/message.json \
https://turnui.xyz/r/streaming-markdown.json \
https://turnui.xyz/r/reasoning.json \
https://turnui.xyz/r/tool-call.json \
https://turnui.xyz/r/thinking.json \
https://turnui.xyz/r/error-notice.json \
https://turnui.xyz/r/scroll-to-bottom.json \
https://turnui.xyz/r/prompt-composer.json \
https://turnui.xyz/r/use-auto-scroll.jsonWith the default model string the SDK talks to the Vercel AI Gateway, which needs AI_GATEWAY_API_KEY in .env.local. To use a provider directly, pass that provider's model instead of the string.
server route
app/api/chat/route.ts receives the conversation, streams the model's answer and runs a sample tool. Reasoning is forwarded with sendReasoning; remove the option if your model does not produce it.
import {
convertToModelMessages,
createUIMessageStreamResponse,
stepCountIs,
streamText,
tool,
toUIMessageStream,
type UIMessage,
} from "ai";
import { z } from "zod";
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),
tools: {
getWeather: tool({
description: "Get the current weather for a city",
inputSchema: z.object({ city: z.string() }),
execute: async ({ city }) => ({ city, temperature: 21, unit: "C" }),
}),
},
stopWhen: stepCountIs(5),
});
return createUIMessageStreamResponse({
stream: toUIMessageStream({ stream: result.stream, sendReasoning: true }),
});
}chat page
app/page.tsx renders each message part with the matching component. useChat posts to /api/chat by default.
"use client";
import { useChat } from "@ai-sdk/react";
import { getToolName, isToolUIPart } from "ai";
import { Message, MessageContent } from "@/components/ui/message";
import { StreamingMarkdown } from "@/components/ui/streaming-markdown";
import { Reasoning } from "@/components/ui/reasoning";
import { ToolCall, type ToolCallState } from "@/components/ui/tool-call";
import { Thinking } from "@/components/ui/thinking";
import { ErrorNotice } from "@/components/ui/error-notice";
import { ScrollToBottom } from "@/components/ui/scroll-to-bottom";
import { PromptComposer } from "@/components/ui/prompt-composer";
import { useAutoScroll } from "@/hooks/use-auto-scroll";
const toolState: Record<string, ToolCallState> = {
"input-streaming": "pending",
"input-available": "running",
"output-available": "success",
"output-error": "error",
};
export default function Chat() {
const { messages, sendMessage, status, stop, error, regenerate } = useChat();
const busy = status === "submitted" || status === "streaming";
const ref = useAutoScroll<HTMLDivElement>(messages);
return (
<main className="mx-auto flex h-dvh max-w-3xl flex-col">
<div className="relative flex min-h-0 flex-1 flex-col">
<div
ref={ref}
role="log"
aria-live="polite"
aria-busy={busy}
aria-label="Conversation"
tabIndex={0}
className="flex-1 space-y-6 overflow-y-auto p-6 outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{messages.map((message, m) => (
<Message key={message.id} role={message.role === "user" ? "user" : "assistant"}>
<MessageContent>
{message.parts.map((part, i) => {
const live = busy && m === messages.length - 1;
if (part.type === "text")
return message.role === "user" ? (
part.text
) : (
<StreamingMarkdown key={i} streaming={live && i === message.parts.length - 1}>
{part.text}
</StreamingMarkdown>
);
if (part.type === "reasoning")
return (
<Reasoning key={i} isStreaming={part.state === "streaming"}>
{part.text}
</Reasoning>
);
if (isToolUIPart(part))
return (
<ToolCall
key={part.toolCallId}
name={getToolName(part)}
state={toolState[part.state] ?? "pending"}
input={part.input}
output={part.output}
error={part.errorText}
/>
);
return null;
})}
</MessageContent>
</Message>
))}
{status === "submitted" && <Thinking />}
{error && (
<ErrorNotice
message={error.message}
onRetry={() => {
regenerate();
ref.current?.focus(); // the notice disappears on retry; keep focus in the conversation
}}
/>
)}
</div>
<ScrollToBottom target={ref} />
</div>
<div className="p-4">
<PromptComposer isStreaming={busy} onStop={stop} onSubmit={(text) => sendMessage({ text })} />
</div>
</main>
);
}how it maps
| from the SDK | component | notes |
|---|---|---|
| text part | StreamingMarkdown | Pass streaming while this is the last part of the message being produced. |
| reasoning part | Reasoning | Open while part.state is streaming, collapses when it ends. |
| tool part | ToolCall | Map the tool state to pending, running, success or error. |
| status | PromptComposer | Pass isStreaming for the stop button. status is submitted, streaming, ready or error. |
| status is submitted | Thinking | Shown until the first token arrives. |
| error | ErrorNotice | Pass regenerate as onRetry. Move focus back into the conversation, since the notice disappears. |
The message list keeps role="log" and sets aria-busy while a reply streams, so screen readers wait for the finished message instead of reading every token.
other backends
Nothing here is specific to the AI SDK. The components take plain props, so anything that gives you text, a status and tool results will work: LangChain, the OpenAI or Anthropic SDKs, or your own server-sent events. Feed the text to StreamingMarkdown as it arrives.