Skip to content
turn

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.

bash
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.json

With 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.

ts
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.

tsx
"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 SDKcomponentnotes
text partStreamingMarkdownPass streaming while this is the last part of the message being produced.
reasoning partReasoningOpen while part.state is streaming, collapses when it ends.
tool partToolCallMap the tool state to pending, running, success or error.
statusPromptComposerPass isStreaming for the stop button. status is submitted, streaming, ready or error.
status is submittedThinkingShown until the first token arrives.
errorErrorNoticePass 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.