Reasoning UI with the Vercel AI SDK

· checked against the Vercel AI SDK docs · 5 min read

With the AI SDK, forward the model's reasoning to the client with sendReasoning: true on toUIMessageStream. It arrives in message.parts as reasoning parts with text and a state of streaming or done. Show a thinking line with a live timer while the reasoning streams, pass its paragraphs as short steps, and mark it done when the answer's first text part arrives, so it folds to "Thought for 12s".

How the AI SDK sends reasoning#

The Chatbot page's Reasoning section says some models send reasoning tokens before the answer and that you forward them with sendReasoning. On the client they are parts of type reasoning with a text property; the docs render them in a <pre>. The Reasoning page covers the provider side: which models return reasoning and how to ask for it.

Two things to plan for in the UI:

  • No reasoning at all. Many models and settings return none. The thinking line should still show from the moment you send, so the wait has a label.
  • Raw text is long. Reasoning can run to pages. A line that says what the model is doing, with the detail one click away, reads better than a wall of grey text.

From parts to the thinking line#

Moment AI SDK signal Thinking state props
Request sent, nothing back status === "submitted" label, startedAt
Reasoning streaming A reasoning part with state: "streaming" steps from its paragraphs, the last one active
Reasoning done, answer starting A text part appears done; the timer freezes and the line folds
Thread reloaded later Parts from history done with elapsedMs you stored
Model returns no reasoning No reasoning part label and timer only, done when text arrives

The code#

The route forwards reasoning. The model and its reasoning options come from the provider docs.

app/api/chat/route.ts
import { convertToModelMessages, createUIMessageStreamResponse, streamText, toUIMessageStream, type UIMessage } from "ai";

export async function POST(req: Request) {
  const { messages }: { messages: UIMessage[] } = await req.json();
  const result = streamText({ model: "openai/gpt-5.5", messages: await convertToModelMessages(messages) });
  return createUIMessageStreamResponse({ stream: toUIMessageStream({ stream: result.stream, sendReasoning: true }) });
}

The client turns the reasoning text into steps and folds the line when the answer begins.

components/assistant-turn.tsx
"use client";

import type { UIMessage } from "ai";
import { ThinkingState, type ThinkingStateStep } from "@/components/pixilui/thinking-state";

// One step per paragraph, its first sentence only. The full text stays one click away in your own panel if you want it.
function stepsFrom(reasoning: string, streaming: boolean): ThinkingStateStep[] {
  const paragraphs = reasoning.split(/\n{2,}/).map((p) => p.trim()).filter(Boolean);
  return paragraphs.map((p, i) => ({
    id: String(i),
    text: (p.split(/(?<=[.!?])\s/)[0] ?? p).replace(/\*\*/g, "").slice(0, 90),
    status: streaming && i === paragraphs.length - 1 ? "active" : "done",
  }));
}

export function AssistantTurn({ message, startedAt }: { message: UIMessage; startedAt: number }) {
  const reasoning = message.parts.filter((part) => part.type === "reasoning");
  const answering = message.parts.some((part) => part.type === "text" && part.text.length > 0);
  const streaming = reasoning.some((part) => part.state === "streaming");
  const text = reasoning.map((part) => part.text).join("\n\n");

  return (
    <ThinkingState
      label="Working through your question"
      steps={stepsFrom(text, streaming && !answering)}
      startedAt={startedAt}
      done={answering}
      doneLabel={(ms) => (ms === null ? "Thought it through" : `Thought for ${Math.max(1, Math.round(ms / 1000))}s`)}
    />
  );
}

Render AssistantTurn above the answer's text parts, with startedAt set when you called sendMessage. Install it with npx shadcn@latest add @pixilui/thinking-state.

What the thinking line adds#

  • A label from the first moment. The line shows before any reasoning arrives, so a model that returns none still gets a labeled wait.
  • A live timer that freezes. startedAt counts; done stops it and the line folds to "Thought for 12s".
  • Steps instead of a transcript. Short lines, the newest active, the rest checked.
  • Reduced motion. No moving light, no rise: the steps and the label change at once.

When the framework already ships a UI#

The AI SDK renders reasoning as a <pre> in its docs and ships no component. AI Elements, Vercel's component set for the AI SDK, has Reasoning and Chain of Thought; its site says it moves into shadcn/ui at the end of November 2026. Keep Reasoning if your product shows the full reasoning text; the AI Elements migration doc maps it to the thinking line when you want a label, steps and a timer instead.

When not to use this#

  • The reasoning is the product. A tutor or an audit tool that must show every line of the model's thinking needs a full text panel.
  • Instant answers. A reply in under a second should not flash a thinking line; show it only after a short delay.
  • Agent runs with tools. When the model calls tools between thoughts, a run timeline shows the steps better.

Questions#

How do I show reasoning tokens with the AI SDK?

Pass sendReasoning: true to toUIMessageStream on the server. On the client, reasoning arrives as reasoning parts in message.parts, each with text and a state.

How do I show "Thought for 12 seconds"?

Note when you sent the message, and mark the thinking line done when the first text part of the answer arrives. The elapsed time between the two is the thinking time; store it with the message to show it again after a reload.

Why are there no reasoning parts in my messages?

Either the model does not return reasoning, or the provider needs it switched on, or the route does not forward it. The AI SDK's Reasoning page covers the provider settings, and sendReasoning controls forwarding.

Sources#