Every AI product eventually needs a chat interface, and building one that feels as polished as ChatGPT or Claude.ai is far harder than it looks. Streaming markdown that doesn't flicker, auto-scroll that stops when the user scrolls up, message editing and regeneration, branch navigation, tool call visualizations, file attachments — each of these is a mini-project on its own.
assistant-ui solves this. It is the most popular open-source React library for AI chat, powering interfaces at startups and enterprises alike. Instead of shipping a monolithic black-box widget, it follows the shadcn/ui philosophy: composable primitives that live in your codebase, styled with Tailwind CSS, that you fully own and customize.
In this tutorial, you will build a complete, production-ready AI chat application with Next.js 15, assistant-ui, and the Vercel AI SDK, backed by Anthropic's Claude. By the end, you will have streaming chat, custom tool rendering, generative UI, and a clear path to production.
What You'll Build
A full-featured AI chat application with:
- Real-time streaming responses with markdown rendering
- Message editing, regeneration, and branch switching (like ChatGPT's edit flow)
- A weather tool with a custom generative UI card rendered inside the conversation
- Auto-scrolling viewport that respects user scroll position
- Keyboard shortcuts, loading states, and accessibility out of the box
Prerequisites
Before starting, ensure you have:
- Node.js 20+ installed
- Familiarity with React and Next.js App Router
- Basic TypeScript knowledge
- An Anthropic API key (or any AI SDK-compatible provider)
Why assistant-ui Instead of Building From Scratch?
assistant-ui handles more than 50 UX details you would otherwise implement manually: smart auto-scroll, streaming text animation, optimistic updates, abort handling, message branching, and full ARIA accessibility.
A quick comparison of the main approaches in 2026:
| Approach | Ownership | Effort | Flexibility |
|---|---|---|---|
| Build from scratch | Full | Very high | Full |
| Embedded chat widgets | None | Low | Low |
| Vercel AI Elements | Full | Medium | High |
| assistant-ui | Full | Low | High |
assistant-ui's killer feature is its primitive architecture. Like Radix UI did for general components, it ships unstyled, composable primitives (ThreadPrimitive, MessagePrimitive, ComposerPrimitive) plus a styled starter layer you scaffold into your project and edit freely.
Step 1: Project Setup
Create a fresh Next.js 15 project:
npx create-next-app@latest assistant-chat --typescript --tailwind --eslint --app
cd assistant-chatNow initialize assistant-ui. The CLI scaffolds the chat components directly into your codebase, shadcn-style:
npx assistant-ui@latest initThis command:
- Installs
@assistant-ui/react,@assistant-ui/react-ai-sdk, and@assistant-ui/react-markdown - Creates styled components under
components/assistant-ui/(thread, composer, markdown text, and more) - Adds required Tailwind configuration and CSS variables
Install the AI SDK and the Anthropic provider for the backend:
npm install ai @ai-sdk/anthropicAdd your API key to .env.local:
ANTHROPIC_API_KEY=sk-ant-...Never commit .env.local to git. The API key must only ever exist on the server — assistant-ui's runtime architecture ensures all model calls go through your backend route, never from the browser.
Step 2: Create the Chat API Route
assistant-ui works with any backend through its runtime system. The most common choice is the AI SDK runtime, which connects to a standard AI SDK route handler.
Create app/api/chat/route.ts:
import { anthropic } from "@ai-sdk/anthropic";
import { streamText, convertToModelMessages, type UIMessage } from "ai";
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: anthropic("claude-sonnet-5"),
system:
"You are a helpful assistant. Be concise and use markdown formatting.",
messages: convertToModelMessages(messages),
});
return result.toUIMessageStreamResponse();
}Three things to note:
streamTextstarts the model call and returns immediately with a streamable resultconvertToModelMessagestransforms the UI message format (which includes tool calls and attachments) into the provider formattoUIMessageStreamResponseemits the SSE stream that assistant-ui's runtime consumes natively
Step 3: Wire Up the Runtime Provider
The runtime is the brain of assistant-ui — it manages message state, streaming, branching, and communication with your API. Wrap your app with the provider.
Create app/assistant.tsx:
"use client";
import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime } from "@assistant-ui/react-ai-sdk";
import { Thread } from "@/components/assistant-ui/thread";
export function Assistant() {
const runtime = useChatRuntime({
api: "/api/chat",
});
return (
<AssistantRuntimeProvider runtime={runtime}>
<main className="h-dvh">
<Thread />
</main>
</AssistantRuntimeProvider>
);
}Then render it from your page. Replace app/page.tsx:
import { Assistant } from "./assistant";
export default function Home() {
return <Assistant />;
}Run the dev server:
npm run devOpen http://localhost:3000 and you already have a working chat: streaming responses, markdown rendering, code blocks with syntax highlighting, copy buttons, message regeneration, and smart auto-scroll. That is the entire baseline setup — two files and one CLI command.
Step 4: Understand the Primitive Architecture
Open components/assistant-ui/thread.tsx. Everything you see on screen is composed from primitives you now own. A simplified view of the structure:
<ThreadPrimitive.Root>
<ThreadPrimitive.Viewport>
<ThreadPrimitive.Messages
components={{
UserMessage,
AssistantMessage,
EditComposer,
}}
/>
</ThreadPrimitive.Viewport>
<Composer />
</ThreadPrimitive.Root>Each primitive handles behavior, not appearance:
ThreadPrimitive.Viewportimplements smart auto-scroll — it follows the stream but stops following the moment the user scrolls up, then offers a "scroll to bottom" affordanceThreadPrimitive.Messagesvirtualizes and renders the message list, mapping message roles to your componentsMessagePrimitive.Partsrenders the parts of a message — text, reasoning, tool calls — each with its own component mappingComposerPrimitivemanages the input: submit on Enter, newline on Shift+Enter, disabled states while streaming, abort on Escape
Because these are your files, customization is direct. Want to change the assistant avatar, reorder action buttons, or add a custom footer to every message? Edit the component — no configuration API to fight, no CSS overrides on foreign class names.
Customizing the welcome screen
Find the ThreadWelcome component in thread.tsx and make it yours:
const ThreadWelcome = () => {
return (
<div className="flex flex-col items-center justify-center py-16">
<h1 className="text-2xl font-semibold">Noqta Assistant</h1>
<p className="text-muted-foreground mt-2">
Ask me anything about your projects.
</p>
<div className="mt-6 flex gap-2">
<ThreadPrimitive.Suggestion
prompt="What can you help me with?"
method="replace"
autoSend
className="rounded-full border px-4 py-2 text-sm hover:bg-muted"
>
What can you help me with?
</ThreadPrimitive.Suggestion>
</div>
</div>
);
};ThreadPrimitive.Suggestion renders a clickable prompt chip that fills (or sends) the composer — the same pattern ChatGPT uses for its starter prompts.
Step 5: Add a Tool with Custom Generative UI
This is where assistant-ui shines. Tools let the model call functions in your backend; tool UIs let you render those calls as rich interactive components inside the conversation instead of raw JSON.
First, define the tool on the server. Update app/api/chat/route.ts:
import { anthropic } from "@ai-sdk/anthropic";
import {
streamText,
convertToModelMessages,
tool,
type UIMessage,
} from "ai";
import { z } from "zod";
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: anthropic("claude-sonnet-5"),
system: "You are a helpful assistant with access to weather data.",
messages: convertToModelMessages(messages),
tools: {
getWeather: tool({
description: "Get the current weather for a city",
inputSchema: z.object({
city: z.string().describe("The city name"),
}),
execute: async ({ city }) => {
// In production, call a real weather API here
const conditions = ["sunny", "cloudy", "rainy", "windy"];
return {
city,
temperature: Math.round(18 + Math.random() * 15),
condition:
conditions[Math.floor(Math.random() * conditions.length)],
humidity: Math.round(40 + Math.random() * 40),
};
},
}),
},
});
return result.toUIMessageStreamResponse();
}Now create the custom UI for this tool. Create components/assistant-ui/weather-tool.tsx:
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
type WeatherArgs = {
city: string;
};
type WeatherResult = {
city: string;
temperature: number;
condition: string;
humidity: number;
};
export const WeatherToolUI = makeAssistantToolUI<WeatherArgs, WeatherResult>({
toolName: "getWeather",
render: ({ args, status, result }) => {
if (status.type === "running") {
return (
<div className="my-2 animate-pulse rounded-xl border bg-muted/50 p-4">
Checking weather for {args.city}...
</div>
);
}
if (status.type === "incomplete" || !result) {
return (
<div className="my-2 rounded-xl border border-destructive p-4">
Could not fetch weather data.
</div>
);
}
return (
<div className="my-2 rounded-xl border bg-gradient-to-br from-sky-50 to-blue-100 p-4 dark:from-sky-950 dark:to-blue-900">
<div className="text-sm text-muted-foreground">{result.city}</div>
<div className="text-3xl font-bold">{result.temperature}°C</div>
<div className="mt-1 flex gap-4 text-sm">
<span className="capitalize">{result.condition}</span>
<span>Humidity: {result.humidity}%</span>
</div>
</div>
);
},
});The render function receives the full lifecycle of the tool call:
status.type === "running"— the model has emitted the call andexecuteis in flight;argsstream in progressively, so you can show them immediatelystatus.type === "complete"—resultcontains whatexecutereturnedstatus.type === "incomplete"— the call was aborted or errored
Register the tool UI by rendering it anywhere inside the provider. Update app/assistant.tsx:
"use client";
import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime } from "@assistant-ui/react-ai-sdk";
import { Thread } from "@/components/assistant-ui/thread";
import { WeatherToolUI } from "@/components/assistant-ui/weather-tool";
export function Assistant() {
const runtime = useChatRuntime({
api: "/api/chat",
});
return (
<AssistantRuntimeProvider runtime={runtime}>
<WeatherToolUI />
<main className="h-dvh">
<Thread />
</main>
</AssistantRuntimeProvider>
);
}Ask the chat "What's the weather in Tunis?" and watch the model call the tool, the loading card appear, and the gradient weather card render inline — a genuine generative UI experience in about 60 lines of code.
Tool UIs are registered by component presence, not configuration. This means you can code-split them, register them conditionally per page, or ship them from separate feature modules — the registry updates automatically on mount and unmount.
Step 6: Message Branching and Editing
assistant-ui ships ChatGPT-style branching with zero extra work. Hover over any user message and click the edit icon: submitting the edit creates a new branch, and a branch picker appears showing something like "2 / 2" with arrows to navigate between alternatives.
The same applies to regeneration — clicking "refresh" on an assistant message creates a sibling branch rather than destroying the previous answer.
The primitives behind this, already wired in your scaffolded thread.tsx:
<BranchPickerPrimitive.Root>
<BranchPickerPrimitive.Previous />
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
<BranchPickerPrimitive.Next />
</BranchPickerPrimitive.Root>Branch state lives in the runtime's internal message tree, so it survives streaming, aborts, and tool calls without any state management on your side.
Step 7: Reading and Controlling Chat State
For features beyond the built-in components — a custom sidebar, analytics, external triggers — assistant-ui exposes granular hooks and a runtime API.
Send a message programmatically (for example, from a button elsewhere in your app):
"use client";
import { useThreadRuntime } from "@assistant-ui/react";
export function QuickAction() {
const threadRuntime = useThreadRuntime();
return (
<button
onClick={() =>
threadRuntime.append({
role: "user",
content: [
{ type: "text", text: "Summarize our conversation so far." },
],
})
}
className="rounded-md border px-3 py-1.5 text-sm"
>
Summarize
</button>
);
}Subscribe to state reactively — for example, disabling app navigation while the model is streaming:
import { useThread } from "@assistant-ui/react";
export function StreamingIndicator() {
const isRunning = useThread((t) => t.isRunning);
return isRunning ? (
<span className="text-xs text-muted-foreground">Generating…</span>
) : null;
}Selectors keep re-renders scoped: the component above only re-renders when isRunning flips, not on every streamed token.
Step 8: Production Hardening
A demo chat and a production chat differ in a handful of critical details.
Rate limiting
Model calls are expensive. Protect your route before launch:
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(20, "1 m"),
});
export async function POST(req: Request) {
const ip = req.headers.get("x-forwarded-for") ?? "anonymous";
const { success } = await ratelimit.limit(ip);
if (!success) {
return new Response("Rate limit exceeded", { status: 429 });
}
// ... streamText call
}Error surfaces
The runtime automatically surfaces route errors in the thread UI. Return meaningful messages rather than letting exceptions bubble into opaque 500s, and keep maxDuration aligned with your host's streaming limits (30–60 seconds covers most conversational turns; long agentic turns may need more).
Persistence
useChatRuntime keeps history in memory per session. For durable, multi-device history you have two production paths:
- Assistant Cloud — assistant-ui's managed persistence layer: thread history, thread list management, and human-approval flows with a few lines of config
- Self-hosted — persist
UIMessagearrays in theonFinishcallback oftoUIMessageStreamResponse, keyed by a thread ID, then hydrate the runtime withinitialMessages
Whichever you choose, persist the UI message format (not the model format) — it is the lossless representation containing tool calls, attachments, and metadata.
Always validate and authorize the thread ID server-side on every request. A common vulnerability in chat apps is trusting a client-supplied thread ID, letting any user load another user's conversation history.
Testing Your Implementation
Verify the complete flow:
- Streaming — send "Write a haiku about Tunisia" and confirm tokens stream in smoothly with markdown rendering
- Auto-scroll — send a long request, scroll up mid-stream, and confirm the viewport stops following; the scroll-to-bottom button should appear
- Tool UI — ask "What's the weather in Sfax?" and confirm the loading card is replaced by the weather card
- Branching — edit your first message, submit, and confirm the branch picker shows two branches you can navigate
- Abort — press Escape (or click stop) mid-generation and confirm the stream halts cleanly with a partial message preserved
Troubleshooting
Messages send but nothing streams back. Check that your route returns result.toUIMessageStreamResponse() — returning result.toTextStreamResponse() produces a plain text stream the runtime does not parse into message parts.
Tool UI never renders. The toolName in makeAssistantToolUI must exactly match the key in the tools object of streamText, and the component must be mounted inside AssistantRuntimeProvider.
Tailwind styles look broken. Confirm the CLI added assistant-ui's paths and CSS variables to your Tailwind setup; with Tailwind v4, check the @source directives in your global CSS.
TypeScript errors on UIMessage. Make sure ai and @assistant-ui/react-ai-sdk are both on their latest majors — the AI SDK v5 message format is required by current assistant-ui runtimes.
Next Steps
- Add file attachments with assistant-ui's attachment adapters (images and PDFs flow into multimodal models)
- Explore human-in-the-loop tool approval, rendering approve/reject buttons inside a tool UI before
executeruns - Connect MCP servers through the AI SDK's MCP client to give your chat external tools without writing each one by hand
- Read our related tutorials: Vercel AI Elements, CopilotKit in-app copilots, and Building MCP servers in TypeScript
Conclusion
You built a production-grade AI chat interface with streaming, generative tool UIs, branching, and programmatic control — in a fraction of the code a from-scratch build would require. assistant-ui's primitive architecture means you never hit the customization wall that embedded widgets impose: every component lives in your repository, styled by your Tailwind theme, shaped by your product.
The pattern to remember: runtime for state, primitives for behavior, your code for appearance. Master those three layers and any chat UX you can sketch — approval flows, multi-agent views, inline dashboards — becomes an afternoon of composition rather than a quarter of infrastructure work.