
Ai Sdk Ui
- 9 installs
- 5 repo stars
- Updated August 5, 2026
- bjornmelin/dev-skills
Ai-sdk-ui is a Claude Code skill giving expert guidance for building chat and generative UIs with AI SDK React hooks like useChat, useObject, and useCompletion.
About
Ai-sdk-ui is a Claude Code skill for building chat and generative UIs with the AI SDK React hooks. It provides a hook-selection guide (useChat for conversations, useObject for structured data, useCompletion for single-turn text) and patterns for status handling, error and retry, message editing, file attachments, custom request options, and message metadata. Developers use it to build streaming chatbots and tool UIs wired to Next.js, Node, Fastify, or Nest backends.
- Guidance for building chat and generative UIs with AI SDK React hooks
- Covers useChat, useObject, useCompletion with a hook-selection decision tree
- Handles tool UIs, message persistence, streaming, and file attachments
Ai Sdk Ui by the numbers
- 9 all-time installs (skills.sh)
- Ranked #1,714 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
ai-sdk-ui capabilities & compatibility
Free skill; the chat UI it builds calls an LLM provider that requires an API key.
- Capabilities
- frontend · api development
- Works with
- vercel · openai
- Use cases
- frontend · ui design · api development
- Pricing
- Bring your own API key
What ai-sdk-ui says it does
AI SDK UI provides framework-agnostic hooks for building interactive chat, completion, and assistant applications with real-time streaming and state management.
Need conversation history + tools? → `useChat`
npx skills add https://github.com/bjornmelin/dev-skills --skill ai-sdk-uiAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 9 |
|---|---|
| repo stars | ★ 5 |
| Last updated | August 5, 2026 |
| Repository | bjornmelin/dev-skills ↗ |
What it does
Build a streaming chat or generative UI with AI SDK React hooks (useChat, useObject, useCompletion) wired to a backend route.
Who is it for?
Building streaming chatbots and generative UIs with AI SDK React hooks
Skip if: Server-side LLM calls and tool wiring, which the AI SDK Core skill covers
When should I use this skill?
Building chatbots with useChat, tool UIs, message persistence, generative UI, or useObject streaming
What you get
A working chat or generative UI with correct hook selection, streaming, tool parts, and error handling.
- chat and generative UI React components
- streaming API routes
By the numbers
- 3-hook selection guide (useChat/useObject/useCompletion)
- 4 status values: submitted, streaming, ready, error
Files
AI SDK UI - Chat & Generative UI Framework
AI SDK UI provides framework-agnostic hooks for building interactive chat, completion, and assistant applications with real-time streaming and state management.
Quick Start: Basic Chat
Client (React)
'use client';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import { useState } from 'react';
export default function Chat() {
const { messages, sendMessage, status } = useChat({
transport: new DefaultChatTransport({ api: '/api/chat' }),
});
const [input, setInput] = useState('');
return (
<>
{messages.map(message => (
<div key={message.id}>
{message.role === 'user' ? 'User: ' : 'AI: '}
{message.parts.map((part, index) =>
part.type === 'text' ? <span key={index}>{part.text}</span> : null
)}
</div>
))}
<form onSubmit={e => {
e.preventDefault();
if (input.trim()) {
sendMessage({ text: input });
setInput('');
}
}}>
<input
value={input}
onChange={e => setInput(e.target.value)}
disabled={status !== 'ready'}
/>
<button type="submit" disabled={status !== 'ready'}>
Submit
</button>
</form>
</>
);
}Server (Next.js App Router)
import { convertToModelMessages, streamText, UIMessage } from 'ai';
import { openai } from '@ai-sdk/openai';
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: openai('gpt-4o'),
system: 'You are a helpful assistant.',
messages: await convertToModelMessages(messages), // v6: now async
});
return result.toUIMessageStreamResponse();
}Hook Selection Guide
| Hook | Use Case | Stream Type | Best For |
|---|---|---|---|
useChat | Multi-turn conversations | Messages with parts (text, tools, files) | Chatbots, assistants, tool-calling UIs |
useObject | Structured data streaming | Typed objects with Zod schema | Forms, dashboards, real-time data |
useCompletion | Single-turn text generation | Plain text | Autocomplete, simple generation |
Decision tree:
- Need conversation history + tools? →
useChat - Need typed/structured streaming data? →
useObject - Need simple text completion? →
useCompletion
Core Patterns
1. Status Management
const { status, stop } = useChat();
// status values: 'submitted' | 'streaming' | 'ready' | 'error'
{(status === 'submitted' || status === 'streaming') && (
<div>
{status === 'submitted' && <Spinner />}
<button onClick={() => stop()}>Stop</button>
</div>
)}2. Error Handling
const { error, reload } = useChat();
{error && (
<>
<div>An error occurred.</div>
<button onClick={() => reload()}>Retry</button>
</>
)}Server-side error messages:
return result.toUIMessageStreamResponse({
onError: error => {
if (error instanceof Error) return error.message;
return 'Unknown error';
},
});3. Message Modification
const { messages, setMessages } = useChat();
const handleDelete = (id: string) => {
setMessages(messages.filter(m => m.id !== id));
};
const handleEdit = (id: string, newText: string) => {
setMessages(messages.map(m =>
m.id === id
? { ...m, parts: [{ type: 'text', text: newText }] }
: m
));
};4. File Attachments
const [files, setFiles] = useState<FileList | undefined>();
<form onSubmit={e => {
e.preventDefault();
sendMessage({ text: input, files });
setFiles(undefined);
}}>
<input
type="file"
onChange={e => setFiles(e.target.files ?? undefined)}
multiple
/>
</form>5. Custom Request Options
// Per-request customization (recommended)
sendMessage(
{ text: input },
{
headers: { Authorization: 'Bearer token' },
body: { temperature: 0.7, user_id: '123' },
metadata: { sessionId: 'abc' },
}
);
// Hook-level configuration
const { sendMessage } = useChat({
transport: new DefaultChatTransport({
api: '/api/chat',
headers: () => ({ Authorization: `Bearer ${getToken()}` }),
body: { systemContext: 'expert' },
}),
});6. Message Metadata
// Server: Attach metadata
return result.toUIMessageStreamResponse({
messageMetadata: ({ part }) => {
if (part.type === 'start') {
return { createdAt: Date.now(), model: 'gpt-4o' };
}
if (part.type === 'finish') {
return { totalTokens: part.totalUsage.totalTokens };
}
},
});// Client: Access metadata
{messages.map(m => (
<div key={m.id}>
{m.metadata?.createdAt && new Date(m.metadata.createdAt).toLocaleString()}
{m.parts.map(part => part.type === 'text' ? part.text : null)}
{m.metadata?.totalTokens && <span>{m.metadata.totalTokens} tokens</span>}
</div>
))}7. Regenerate & Stop
const { regenerate, stop, status } = useChat();
<>
<button onClick={stop} disabled={status !== 'streaming'}>
Stop
</button>
<button onClick={regenerate} disabled={!(status === 'ready' || status === 'error')}>
Regenerate
</button>
</>Message Parts Type Reference
Messages use a parts array instead of content for flexible multi-modal rendering:
type MessagePart =
| { type: 'text'; text: string }
| { type: 'file'; filename: string; mediaType: string; url: string }
| { type: 'tool-invocation'; toolName: string; input: unknown; result?: unknown }
| { type: 'tool-result'; toolName: string; result: unknown }
| { type: 'reasoning'; text: string } // DeepSeek R1, Claude 3.7 Sonnet
| { type: 'source-url'; id: string; url: string; title?: string } // Perplexity, Google
| { type: 'source-document'; id: string; title?: string };
// Render pattern
{message.parts.map((part, index) => {
switch (part.type) {
case 'text':
return <span key={index}>{part.text}</span>;
case 'file':
return part.mediaType.startsWith('image/')
? <img key={index} src={part.url} alt={part.filename} />
: null;
case 'reasoning':
return <pre key={index}>{part.text}</pre>;
case 'source-url':
return <a key={index} href={part.url}>{part.title ?? 'Source'}</a>;
case 'tool-invocation':
return <ToolUI key={index} tool={part} />;
default:
return null;
}
})}Framework Support
| Framework | Package | Hooks |
|---|---|---|
| React | @ai-sdk/react | useChat, useCompletion, useObject |
| Vue.js | @ai-sdk/vue | useChat, useCompletion, useObject |
| Svelte | @ai-sdk/svelte | Chat, Completion, StructuredObject |
| Angular | @ai-sdk/angular | Chat, Completion, StructuredObject |
| SolidJS | ai-sdk-solid (community) | useChat, useCompletion, useObject |
AI Elements (shadcn/ui Components)
Pre-built UI components for chat interfaces: https://ai-sdk.dev/elements
Includes: Message bubbles, input fields, tool UIs, and more.
Reference Navigation
| Reference | Topics |
|---|---|
| [usechat-fundamentals.md](./usechat-fundamentals.md) | Hook API, transport config, status lifecycle, message state |
| [tool-integration.md](./tool-integration.md) | Tool calling, client/server execution, tool approval, type inference |
| [generative-ui.md](./generative-ui.md) | React components in streams, dynamic UIs, RSC integration |
| [persistence.md](./persistence.md) | Message storage, resume streams, optimistic updates, sync patterns |
| [hooks-reference.md](./hooks-reference.md) | Complete API for useChat/useObject/useCompletion, options reference |
| [backend.md](./backend.md) | Next.js/Node/Fastify/Nest routes, convertToModelMessages, toUIMessageStreamResponse |
| [production.md](./production.md) | Error boundaries, retry strategies, throttling, security best practices |
| [migration.md](./migration.md) | v6 migration guide, breaking changes, codemod usage |
Event Callbacks
const { messages } = useChat({
onFinish: ({ message, messages, isAbort, isDisconnect, isError }) => {
// Log completion, update analytics, trigger side effects
if (!isError) logMessage(message);
},
onError: error => {
// Custom error handling, fallback UI
Sentry.captureException(error);
},
onData: data => {
// Process data parts, validate responses
// Throw error to abort processing
},
});Advanced: Custom Transport
const { sendMessage } = useChat({
transport: new DefaultChatTransport({
prepareSendMessagesRequest: ({ id, messages, trigger, messageId }) => {
if (trigger === 'submit-user-message') {
return {
body: {
id,
message: messages[messages.length - 1],
messageId,
},
};
}
// Handle regenerate, custom triggers
},
}),
});Type Inference for Tools
import { InferUITools, InferAgentUIMessage, ToolSet, UIMessage } from 'ai';
const tools = {
weather: {
description: 'Get weather',
inputSchema: z.object({ location: z.string() }),
execute: async ({ location }) => `Sunny in ${location}`,
},
} satisfies ToolSet;
type MyUITools = InferUITools<typeof tools>;
type MyUIMessage = UIMessage<never, never, MyUITools>;
// Or for agent messages:
// type MyAgentUIMessage = InferAgentUIMessage<typeof myAgent>;
const { messages } = useChat<MyUIMessage>();Reasoning & Sources
// Enable reasoning (DeepSeek R1, Claude 3.7 Sonnet)
return result.toUIMessageStreamResponse({ sendReasoning: true });
// Enable sources (Perplexity, Google)
return result.toUIMessageStreamResponse({ sendSources: true });// Render reasoning and sources
{message.parts.map(part => {
if (part.type === 'reasoning') return <pre>{part.text}</pre>;
if (part.type === 'source-url') return <a href={part.url}>{part.title}</a>;
})}Performance: Throttle Updates
const { messages } = useChat({
experimental_throttle: 50, // React only: throttle to 50ms
});Plain Text Streams
import { TextStreamChatTransport } from 'ai';
const { messages } = useChat({
transport: new TextStreamChatTransport({ api: '/api/chat' }),
});Note: Tools, usage, and finish reasons unavailable with TextStreamChatTransport.
Backend Integration Reference
Server-side patterns for Next.js, Node.js, Fastify, and Nest.js.
Next.js App Router
Standard pattern with toUIMessageStreamResponse:
// app/api/chat/route.ts
import { streamText, convertToModelMessages, UIMessage, stepCountIs } from 'ai';
export const maxDuration = 30; // Allow 30 second responses
export async function POST(request: Request) {
const { messages }: { messages: UIMessage[] } = await request.json();
const result = streamText({
model: 'openai/gpt-4o',
system: 'You are a helpful assistant.',
messages: await convertToModelMessages(messages), // v6: now async
tools: { /* ... */ },
stopWhen: stepCountIs(5),
});
return result.toUIMessageStreamResponse();
}Node.js HTTP Server
Use pipeUIMessageStreamToResponse:
import { createServer } from 'http';
import { streamText, convertToModelMessages, pipeUIMessageStreamToResponse } from 'ai';
const server = createServer(async (req, res) => {
if (req.method === 'POST' && req.url === '/api/chat') {
let body = '';
for await (const chunk of req) body += chunk;
const { messages } = JSON.parse(body);
const result = streamText({
model: 'openai/gpt-4o',
messages: await convertToModelMessages(messages), // v6: now async
});
// Pipe stream directly to response
pipeUIMessageStreamToResponse(result.toUIMessageStream(), res);
}
});
server.listen(3000);Fastify
Set headers and pipe stream:
import Fastify from 'fastify';
import { streamText, convertToModelMessages } from 'ai';
const fastify = Fastify();
fastify.post('/api/chat', async (request, reply) => {
const { messages } = request.body as { messages: UIMessage[] };
const result = streamText({
model: 'openai/gpt-4o',
messages: await convertToModelMessages(messages), // v6: now async
});
// Set streaming headers
reply.header('Content-Type', 'text/plain; charset=utf-8');
reply.header('Transfer-Encoding', 'chunked');
return reply.send(result.toUIMessageStream());
});
fastify.listen({ port: 3000 });Nest.js
Use @Res() decorator for streaming:
import { Controller, Post, Body, Res } from '@nestjs/common';
import { Response } from 'express';
import { streamText, convertToModelMessages, pipeUIMessageStreamToResponse } from 'ai';
@Controller('api/chat')
export class ChatController {
@Post()
async chat(
@Body() body: { messages: UIMessage[] },
@Res() res: Response
) {
const result = streamText({
model: 'openai/gpt-4o',
messages: await convertToModelMessages(body.messages), // v6: now async
});
pipeUIMessageStreamToResponse(result.toUIMessageStream(), res);
}
}createUIMessageStream
Create custom streams with data:
import { createUIMessageStream, streamText, convertToModelMessages } from 'ai';
export async function POST(request: Request) {
const { messages } = await request.json();
const stream = createUIMessageStream({
async execute(writer) {
// Write custom data
writer.write({ type: 'custom', data: { status: 'starting' } });
// Stream from model
const result = streamText({
model: 'openai/gpt-4o',
messages: await convertToModelMessages(messages), // v6: now async
});
// Forward model stream
for await (const chunk of result.fullStream) {
writer.write(chunk);
}
// Write final custom data
writer.write({ type: 'custom', data: { status: 'complete' } });
},
});
return new Response(stream.readable, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}Streaming Custom Data
Add metadata to responses:
// Server
const result = streamText({
model: 'openai/gpt-4o',
messages: await convertToModelMessages(messages), // v6: now async
});
return result.toUIMessageStreamResponse({
// Add sources for RAG
sources: [
{ id: 'doc-1', title: 'User Guide', url: '/docs/guide' },
{ id: 'doc-2', title: 'FAQ', url: '/docs/faq' },
],
});// Client
const { messages, sources } = useChat({
transport: new DefaultChatTransport({ api: '/api/chat' }),
});
// sources is populated from server responseError Handling
export async function POST(request: Request) {
try {
const { messages } = await request.json();
const result = streamText({
model: 'openai/gpt-4o',
messages: await convertToModelMessages(messages), // v6: now async
});
return result.toUIMessageStreamResponse({
onError: (error) => {
// Customize error message sent to client
if (error instanceof RateLimitError) {
return 'Rate limit exceeded. Please try again.';
}
return 'An error occurred.';
},
});
} catch (error) {
return new Response('Invalid request', { status: 400 });
}
}With Authentication
import { auth } from '@/lib/auth';
export async function POST(request: Request) {
const session = await auth();
if (!session) {
return new Response('Unauthorized', { status: 401 });
}
const { messages } = await request.json();
const result = streamText({
model: 'openai/gpt-4o',
messages: await convertToModelMessages(messages), // v6: now async
// Use user context
system: `You are helping ${session.user.name}.
Their preferences: ${session.user.preferences}`,
});
return result.toUIMessageStreamResponse();
}Agent Responses
Use createAgentUIStreamResponse for ToolLoopAgent:
import { createAgentUIStreamResponse } from 'ai';
import { myAgent } from '@/ai/agents/my-agent';
export async function POST(request: Request) {
const { messages, options } = await request.json();
return createAgentUIStreamResponse({
agent: myAgent,
messages,
options, // Passed to agent's callOptionsSchema
onFinish({ steps, usage }) {
// Log completion
console.log('Agent finished', { stepCount: steps.length });
},
});
}Ensuring Stream Completion
Use onFinish callbacks to ensure post-stream logic runs even on client abort:
import { streamText, convertToModelMessages } from 'ai';
export async function POST(request: Request) {
const { messages } = await request.json();
const result = streamText({
model: 'openai/gpt-4o',
messages: await convertToModelMessages(messages),
// onFinish runs even if client disconnects
onFinish: async ({ text, usage }) => {
await saveToDatabase(text);
await logUsage(usage);
},
onError: async (error) => {
await logError(error);
},
});
return result.toUIMessageStreamResponse();
}Note: v6'stoUIMessageStreamResponse()handles stream lifecycle automatically. The deprecatedconsumeSseStream()pattern is no longer needed—useonFinishandonErrorcallbacks instead.
Best Practices
1. Set maxDuration: Increase timeout for long responses 2. Await convertToModelMessages: v6 requires async call 3. Error handling: Return user-friendly error messages 4. Authentication: Validate sessions before processing 5. Logging: Log completions via onFinish callback 6. Use onFinish: Ensure DB writes/logging complete even on abort
Keep file under 300 lines.
Generative UI Reference
Building dynamic UI components from LLM tool outputs.
How It Works
1. Model receives prompt + available tools 2. Model calls a tool based on context 3. Tool executes and returns data 4. Data is passed to React component 5. Component renders in chat UI
Basic Setup
1. Define Tool
// ai/tools.ts
import { tool } from 'ai';
import { z } from 'zod';
export const weatherTool = tool({
description: 'Display weather for a location',
inputSchema: z.object({
location: z.string().describe('The location to get weather for'),
}),
execute: async ({ location }) => {
// Simulate API call
await new Promise(r => setTimeout(r, 1000));
return { weather: 'Sunny', temperature: 75, location };
},
});
export const tools = { displayWeather: weatherTool };2. Add to API Route
// app/api/chat/route.ts
import { streamText, convertToModelMessages, UIMessage, stepCountIs } from 'ai';
import { tools } from '@/ai/tools';
export async function POST(request: Request) {
const { messages }: { messages: UIMessage[] } = await request.json();
const result = streamText({
model: 'openai/gpt-4o',
system: 'You are a helpful assistant!',
messages: await convertToModelMessages(messages), // v6: now async
tools,
stopWhen: stepCountIs(5),
});
return result.toUIMessageStreamResponse();
}3. Create UI Component
// components/weather.tsx
type WeatherProps = {
temperature: number;
weather: string;
location: string;
};
export const Weather = ({ temperature, weather, location }: WeatherProps) => (
<div className="p-4 border rounded-lg bg-blue-50">
<h2 className="font-bold">Weather in {location}</h2>
<p>Condition: {weather}</p>
<p>Temperature: {temperature}°F</p>
</div>
);4. Render in Chat
// app/page.tsx
'use client';
import { useChat } from '@ai-sdk/react';
import { Weather } from '@/components/weather';
export default function Chat() {
const [input, setInput] = useState('');
const { messages, sendMessage } = useChat();
return (
<div>
{messages.map(message => (
<div key={message.id}>
<strong>{message.role}:</strong>
{message.parts.map((part, index) => {
if (part.type === 'text') {
return <span key={index}>{part.text}</span>;
}
// Typed tool part: tool-${toolName}
if (part.type === 'tool-displayWeather') {
switch (part.state) {
case 'input-available':
return <div key={index}>Loading weather...</div>;
case 'output-available':
return <Weather key={index} {...part.output} />;
case 'output-error':
return <div key={index}>Error: {part.errorText}</div>;
}
}
return null;
})}
</div>
))}
<form onSubmit={e => {
e.preventDefault();
sendMessage({ text: input });
setInput('');
}}>
<input value={input} onChange={e => setInput(e.target.value)} />
<button type="submit">Send</button>
</form>
</div>
);
}Adding Multiple Tools
// ai/tools.ts
export const stockTool = tool({
description: 'Get stock price',
inputSchema: z.object({
symbol: z.string().describe('Stock symbol'),
}),
execute: async ({ symbol }) => {
return { symbol, price: 150.25, change: +2.5 };
},
});
export const tools = {
displayWeather: weatherTool,
getStockPrice: stockTool,
};// Render both tools
{message.parts.map((part, index) => {
switch (part.type) {
case 'text':
return <span key={index}>{part.text}</span>;
case 'tool-displayWeather':
return part.state === 'output-available'
? <Weather key={index} {...part.output} />
: <Skeleton key={index} />;
case 'tool-getStockPrice':
return part.state === 'output-available'
? <Stock key={index} {...part.output} />
: <Skeleton key={index} />;
}
})}State-Aware Rendering
Handle all tool states for best UX:
function ToolRenderer({ part }: { part: ToolPart }) {
switch (part.state) {
case 'input-streaming':
// Tool input being streamed
return <StreamingIndicator args={part.input} />;
case 'input-available':
// Input complete, executing
return <LoadingSpinner label={`Getting ${part.input.location}...`} />;
case 'output-available':
// Success - render component
return <Weather {...part.output} />;
case 'output-error':
// Error state
return <ErrorCard message={part.errorText} />;
}
}Progressive Loading
Show partial data as it streams:
case 'input-streaming':
// Show what we know so far
return (
<div className="animate-pulse">
<p>Searching for: {part.input?.location || '...'}</p>
</div>
);Charts and Visualizations
const chartTool = tool({
description: 'Display a chart',
inputSchema: z.object({
type: z.enum(['bar', 'line', 'pie']),
data: z.array(z.object({
label: z.string(),
value: z.number(),
})),
title: z.string(),
}),
execute: async (input) => input, // Pass through
});
// Render
case 'tool-displayChart':
if (part.state === 'output-available') {
return <Chart type={part.output.type} data={part.output.data} />;
}Best Practices
1. State handling: Always handle all 5 tool states (including approval-requested) 2. Loading states: Show meaningful loading indicators 3. Error handling: Display user-friendly error messages 4. Type safety: Use typed tool parts (tool-${name}) 5. Accessibility: Ensure generated UI is accessible 6. Streaming: Leverage input-streaming for progressive UX 7. AI Elements: Consider using https://ai-sdk.dev/elements for pre-built components
Hooks Reference
useObject and useCompletion hooks for specialized streaming.
useObject
Stream structured JSON data with type safety.
Basic Usage
'use client';
import { useObject } from '@ai-sdk/react';
import { z } from 'zod';
const recipeSchema = z.object({
name: z.string(),
ingredients: z.array(z.object({
name: z.string(),
amount: z.string(),
})),
steps: z.array(z.string()),
});
export default function RecipeGenerator() {
const { object, submit, isLoading, error } = useObject({
api: '/api/generate-recipe',
schema: recipeSchema,
});
return (
<div>
<button onClick={() => submit({ prompt: 'chocolate chip cookies' })}>
Generate Recipe
</button>
{isLoading && <div>Generating...</div>}
{error && <div>Error: {error.message}</div>}
{/* Render partial data as it streams */}
{object && (
<div>
<h2>{object.name ?? 'Loading name...'}</h2>
{object.ingredients?.map((ing, i) => (
<div key={i}>{ing.name}: {ing.amount}</div>
))}
<ol>
{object.steps?.map((step, i) => (
<li key={i}>{step}</li>
))}
</ol>
</div>
)}
</div>
);
}API Route
// app/api/generate-recipe/route.ts
import { streamObject } from 'ai';
import { z } from 'zod';
export async function POST(request: Request) {
const { prompt } = await request.json();
const result = streamObject({
model: 'openai/gpt-4o',
schema: z.object({
name: z.string(),
ingredients: z.array(z.object({
name: z.string(),
amount: z.string(),
})),
steps: z.array(z.string()),
}),
prompt: `Generate a recipe for: ${prompt}`,
});
return result.toTextStreamResponse();
}Partial Rendering
Object fields stream progressively:
// object updates as data streams:
// { name: undefined, ingredients: undefined, steps: undefined }
// { name: "Chocolate", ingredients: undefined, steps: undefined }
// { name: "Chocolate Chip Cookies", ingredients: [{ name: "flour" }], ... }
// { name: "Chocolate Chip Cookies", ingredients: [...], steps: [...] }With Custom Output
const { object, submit } = useObject({
api: '/api/generate',
schema: mySchema,
onFinish: ({ object }) => {
// Final complete object
console.log('Generated:', object);
},
onError: (error) => {
console.error('Generation failed:', error);
},
});useCompletion
Stream text completions (non-chat).
Basic Usage
'use client';
import { useCompletion } from '@ai-sdk/react';
export default function TextCompletion() {
const {
completion, // Streamed text
input, // Input state
handleInputChange,
handleSubmit,
isLoading,
error,
} = useCompletion({
api: '/api/completion',
});
return (
<div>
<form onSubmit={handleSubmit}>
<input
value={input}
onChange={handleInputChange}
placeholder="Start typing..."
/>
<button type="submit" disabled={isLoading}>
Complete
</button>
</form>
{completion && (
<div className="whitespace-pre-wrap">{completion}</div>
)}
</div>
);
}API Route
// app/api/completion/route.ts
import { streamText } from 'ai';
export async function POST(request: Request) {
const { prompt } = await request.json();
const result = streamText({
model: 'openai/gpt-4o',
prompt: `Complete this text: ${prompt}`,
});
return result.toTextStreamResponse();
}With Body and Headers
const { completion, handleSubmit } = useCompletion({
api: '/api/completion',
body: {
model: 'gpt-4o',
temperature: 0.7,
},
headers: {
'Authorization': `Bearer ${token}`,
},
});Shared Patterns
Cancellation
const { stop, isLoading } = useObject({ /* ... */ });
// or
const { stop, isLoading } = useCompletion({ /* ... */ });
// Cancel ongoing stream
<button onClick={stop} disabled={!isLoading}>
Stop
</button>Callbacks
useObject({
api: '/api/generate',
schema: mySchema,
onFinish: ({ object }) => {
// Called when streaming completes
saveToDatabase(object);
},
onError: (error) => {
// Handle errors
toast.error(error.message);
},
});Throttling
Control UI update frequency:
useObject({
api: '/api/generate',
schema: mySchema,
// Note: Still experimental in v6
experimental_throttle: 100, // Update UI every 100ms max
});When to Use
| Hook | Use Case |
|---|---|
| useChat | Multi-turn conversation |
| useObject | Structured data generation |
| useCompletion | Single text completion |
useObject vs useChat
// useObject: Single structured output
const { object } = useObject({
schema: productSchema,
});
// useChat: Conversation with optional tools
const { messages } = useChat({
tools: { /* ... */ },
});Type Safety
import { z } from 'zod';
const schema = z.object({
title: z.string(),
items: z.array(z.string()),
});
type GeneratedData = z.infer<typeof schema>;
const { object } = useObject<GeneratedData>({
api: '/api/generate',
schema,
});
// object is typed as Partial<GeneratedData> during streaming
// TypeScript knows object.title is string | undefinedAI SDK UI v6 Migration Guide
Complete guide for migrating to AI SDK v6 stable release.
Automated Migration
Step 1: Run Codemod
npx @ai-sdk/codemod v6The codemod automatically handles most breaking changes.
Step 2: Update Packages
pnpm add ai@^6.0.3 @ai-sdk/react@^3.0.3Step 3: Manual Fixes
Async convertToModelMessages
The convertToModelMessages function is now async and must be awaited:
// Before
const messages = convertToModelMessages(uiMessages);
// After
const messages = await convertToModelMessages(uiMessages);Helper Function Renames
Static tool helpers have been renamed:
// Before
if (isToolUIPart(part)) {
const name = getToolName(part);
}
// After (for static tools only)
if (isStaticToolUIPart(part)) {
const name = getStaticToolName(part);
}
// Or for both static and dynamic tools:
// (renamed from isToolOrDynamicToolUIPart/getToolOrDynamicToolName)
if (isToolUIPart(part)) {
const name = getToolName(part);
}Step 4: Type Check
pnpm type-checkBreaking Changes Reference
| Before | After |
|---|---|
convertToModelMessages() | await convertToModelMessages() |
isToolUIPart() | isStaticToolUIPart() |
isToolOrDynamicToolUIPart() | isToolUIPart() |
getToolName() | getStaticToolName() |
getToolOrDynamicToolName() | getToolName() |
New Features in v6
Tool Approval
Require user approval before executing sensitive tools:
Server Configuration
const result = streamText({
model: 'openai/gpt-4o',
messages: await convertToModelMessages(messages),
tools: {
deleteFile: {
description: 'Delete a file',
inputSchema: z.object({ path: z.string() }),
needsApproval: true, // New: requires user approval
execute: async ({ path }) => {
await fs.unlink(path);
return { deleted: path };
},
},
},
});Client Approval Handling
const { messages, addToolApprovalResponse } = useChat({
transport: new DefaultChatTransport({ api: '/api/chat' }),
});
// Handle approval-requested state
{message.parts.map((part, index) => {
if (part.type === 'tool-deleteFile') {
if (part.state === 'approval-requested') {
return (
<div key={index}>
<p>Delete {part.input.path}?</p>
<button onClick={() => addToolApprovalResponse({
id: part.approval.id,
approved: true,
})}>
Approve
</button>
<button onClick={() => addToolApprovalResponse({
id: part.approval.id,
approved: false,
})}>
Deny
</button>
</div>
);
}
}
})}New Tool Part State
The approval-requested state is now available for tool parts:
| State | Description |
|---|---|
input-streaming | Tool input being streamed |
input-available | Tool input complete, waiting for execution |
approval-requested | Tool requires user approval |
output-available | Tool execution complete with output |
output-error | Tool execution failed |
Type Helpers
New type inference helpers:
import { InferUITools, InferAgentUIMessage, ToolSet, UIMessage } from 'ai';
const tools = { /* ... */ } satisfies ToolSet;
// Infer tool types from tool definitions
type MyUITools = InferUITools<typeof tools>;
// Infer message type from agent
type MyAgentUIMessage = InferAgentUIMessage<typeof myAgent>;Stream Completion with onFinish
Use onFinish callbacks to ensure post-stream logic runs even on client abort:
import { streamText, convertToModelMessages } from 'ai';
export async function POST(request: Request) {
const { messages } = await request.json();
const result = streamText({
model: 'openai/gpt-4o',
messages: await convertToModelMessages(messages),
// onFinish runs even if client disconnects
onFinish: async ({ text, usage }) => {
await saveToDatabase(text);
await logUsage(usage);
},
onError: async (error) => {
await logError(error);
},
});
return result.toUIMessageStreamResponse();
}Note: v6's toUIMessageStreamResponse() handles stream lifecycle automatically. No additional helper needed.AI Elements
Pre-built shadcn/ui components for chat UIs:
- https://ai-sdk.dev/elements
Migration Checklist
- [ ] Run
npx @ai-sdk/codemod v6 - [ ] Update packages to v6 versions
- [ ] Add
awaitto allconvertToModelMessages()calls - [ ] Rename helper functions if used directly
- [ ] Add
approval-requestedstate handling for tools withneedsApproval - [ ] Run
pnpm type-checkto verify - [ ] Test all chat functionality
Common Issues
"convertToModelMessages is not a function"
Ensure you're importing from ai:
import { convertToModelMessages } from 'ai';Tool approval not working
1. Ensure needsApproval: true is set on server-side tool definition 2. Handle approval-requested state in client rendering 3. Call addToolApprovalResponse with correct id from part.approval.id
Type errors after migration
Run the codemod first, then check for:
1. Missing await on convertToModelMessages 2. Renamed helper functions 3. New tool part states in switch statements
Message Persistence Reference
Storing and loading chat messages with AI SDK UI.
Basic Pattern
'use client';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
export default function Chat({ chatId }: { chatId: string }) {
const [initialMessages, setInitialMessages] = useState<UIMessage[]>([]);
// Load chat history on mount
useEffect(() => {
async function loadChat() {
const messages = await db.messages.findMany({
where: { chatId },
orderBy: { createdAt: 'asc' },
});
setInitialMessages(messages);
}
loadChat();
}, [chatId]);
const { messages, sendMessage } = useChat({
transport: new DefaultChatTransport({ api: '/api/chat' }),
initialMessages,
});
return (/* ... */);
}Server-Side Persistence
Save messages in onFinish callback:
// app/api/chat/route.ts
import { streamText, convertToModelMessages, UIMessage } from 'ai';
export async function POST(request: Request) {
const { messages, chatId }: { messages: UIMessage[]; chatId: string } =
await request.json();
const result = streamText({
model: 'openai/gpt-4o',
messages: await convertToModelMessages(messages), // v6: now async
onFinish: async ({ text, usage }) => {
// Save assistant message to database
await db.messages.create({
data: {
chatId,
role: 'assistant',
content: text,
tokenUsage: usage?.totalTokens,
},
});
},
});
return result.toUIMessageStreamResponse();
}Server-Side ID Generation
Generate message IDs on server for consistency:
// app/api/chat/route.ts
import { generateId } from 'ai';
export async function POST(request: Request) {
const { messages, chatId } = await request.json();
const result = streamText({
model: 'openai/gpt-4o',
messages: await convertToModelMessages(messages), // v6: now async
});
// Generate ID before streaming
const messageId = generateId();
return result.toUIMessageStreamResponse({
messageId, // Client will use this ID
});
}Validate UIMessages
Ensure loaded messages are valid:
import { validateUIMessages } from 'ai';
async function loadChat(chatId: string) {
const rawMessages = await db.messages.findMany({
where: { chatId },
});
// Validate structure and types
const validMessages = validateUIMessages(rawMessages);
return validMessages;
}Stream Resumption
Resume interrupted streams after client disconnect:
Server Setup
// app/api/chat/route.ts
export async function POST(request: Request) {
const { messages, chatId } = await request.json();
const result = streamText({
model: 'openai/gpt-4o',
messages: await convertToModelMessages(messages), // v6: now async
});
const messageId = generateId();
// Store stream for potential resumption
await storeStream(chatId, messageId, result);
return result.toUIMessageStreamResponse({ messageId });
}
// GET endpoint for resumption
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const chatId = searchParams.get('chatId');
const messageId = searchParams.get('messageId');
const storedStream = await getStoredStream(chatId, messageId);
if (!storedStream) {
return new Response('Stream not found', { status: 404 });
}
return storedStream.toUIMessageStreamResponse({ messageId });
}Client Setup
const { messages, sendMessage } = useChat({
transport: new DefaultChatTransport({
api: '/api/chat',
resume: true, // Enable stream resumption
}),
});Ensuring Persistence on Client Disconnect
Use onFinish callbacks to ensure messages are saved even if the client disconnects:
// app/api/chat/route.ts
import { streamText, convertToModelMessages } from 'ai';
export async function POST(request: Request) {
const { messages, chatId } = await request.json();
const result = streamText({
model: 'openai/gpt-4o',
messages: await convertToModelMessages(messages), // v6: now async
// onFinish runs even if client disconnects
onFinish: async ({ text, usage }) => {
await db.messages.create({
data: { chatId, role: 'assistant', content: text },
});
await logUsage(chatId, usage);
},
onError: async (error) => {
await logError(chatId, error);
},
});
return result.toUIMessageStreamResponse();
}Note: v6'stoUIMessageStreamResponse()handles stream lifecycle automatically. The deprecatedconsumeSseStream()pattern is no longer needed—useonFinishandonErrorcallbacks instead.
Creating New Chats
// app/api/chats/route.ts
export async function POST(request: Request) {
const { title } = await request.json();
const chat = await db.chats.create({
data: {
id: generateId(),
title,
createdAt: new Date(),
},
});
return Response.json({ chatId: chat.id });
}// Client
async function createNewChat() {
const res = await fetch('/api/chats', {
method: 'POST',
body: JSON.stringify({ title: 'New Chat' }),
});
const { chatId } = await res.json();
router.push(`/chat/${chatId}`);
}Full Example
'use client';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport, validateUIMessages, UIMessage } from 'ai';
import { useEffect, useState } from 'react';
export default function Chat({ chatId }: { chatId: string }) {
const [initialMessages, setInitialMessages] = useState<UIMessage[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function loadChat() {
try {
const res = await fetch(`/api/chats/${chatId}/messages`);
const messages = await res.json();
setInitialMessages(validateUIMessages(messages));
} finally {
setLoading(false);
}
}
loadChat();
}, [chatId]);
const { messages, sendMessage, status } = useChat({
transport: new DefaultChatTransport({
api: '/api/chat',
body: { chatId }, // Include chatId in requests
resume: true,
}),
initialMessages,
onFinish: async (message) => {
// Save user message
await fetch(`/api/chats/${chatId}/messages`, {
method: 'POST',
body: JSON.stringify({ role: 'user', content: message.content }),
});
},
});
if (loading) return <div>Loading chat...</div>;
return (
<div>
{messages.map(m => (
<Message key={m.id} message={m} />
))}
{/* ... input form */}
</div>
);
}Best Practices
1. Validate on load: Always validate stored messages 2. Server-side IDs: Generate IDs on server for consistency 3. Use onFinish: Ensure DB persistence even on client disconnect 4. Resume support: Enable for long-running responses 5. Include chatId: Pass chatId in all API requests 6. Error handling: Handle load/save failures gracefully 7. Await convertToModelMessages: v6 requires async call
Production UI Patterns
Best practices for production chat UIs with AI SDK.
Error Handling
Hook-Level Errors
const { messages, error, sendMessage } = useChat({
transport: new DefaultChatTransport({ api: '/api/chat' }),
onError: (error) => {
// Log to monitoring
console.error('Chat error:', error);
// Show toast notification
toast.error('Failed to send message. Please try again.');
},
});
// Render error state
{error && (
<div className="bg-red-50 p-4 rounded">
<p>Something went wrong.</p>
<button onClick={() => sendMessage(lastMessage)}>
Retry
</button>
</div>
)}Masked Errors
Server errors are masked by default. Customize:
// Server
return result.toUIMessageStreamResponse({
onError: (error) => {
if (error instanceof RateLimitError) {
return 'Rate limit exceeded. Please wait a moment.';
}
if (error instanceof InvalidInputError) {
return 'Invalid input. Please check your message.';
}
// Return generic message for other errors
return 'Something went wrong. Please try again.';
},
});Warnings
Handle non-fatal issues:
const { messages, warnings } = useChat({ /* ... */ });
{warnings.length > 0 && (
<div className="bg-yellow-50 p-2 text-sm">
{warnings.map((warning, i) => (
<p key={i}>{warning}</p>
))}
</div>
)}Message Metadata
Add custom data to messages:
// Server
import { createUIMessageStream } from 'ai';
const stream = createUIMessageStream({
async execute(writer) {
const result = streamText({
model: 'openai/gpt-4o',
messages: await convertToModelMessages(messages), // v6: now async
});
// Add metadata to assistant message
writer.writeMessageMetadata({
model: 'gpt-4o',
timestamp: Date.now(),
cost: calculateCost(result.usage),
});
for await (const chunk of result.fullStream) {
writer.write(chunk);
}
},
});// Client - access metadata
{messages.map(m => (
<div key={m.id}>
<p>{m.content}</p>
{m.metadata && (
<span className="text-xs text-gray-500">
Model: {m.metadata.model} | Cost: ${m.metadata.cost}
</span>
)}
</div>
))}Custom Transport
Customize request/response handling:
import { ChatTransport } from 'ai';
class CustomTransport implements ChatTransport {
async send(options: { messages: UIMessage[]; abortSignal?: AbortSignal }) {
const response = await fetch('/api/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Request-ID': crypto.randomUUID(),
},
body: JSON.stringify({
messages: options.messages,
timestamp: Date.now(),
}),
signal: options.abortSignal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response;
}
}
const { messages } = useChat({
transport: new CustomTransport(),
});Sources for RAG
Display document sources:
// Server
return result.toUIMessageStreamResponse({
sources: retrievedDocuments.map(doc => ({
id: doc.id,
title: doc.title,
url: doc.url,
snippet: doc.content.slice(0, 200),
})),
});// Client
const { messages, sources } = useChat({ /* ... */ });
{sources.length > 0 && (
<div className="border-t mt-4 pt-4">
<h4 className="font-bold">Sources</h4>
{sources.map(source => (
<a key={source.id} href={source.url} className="block">
{source.title}
</a>
))}
</div>
)}Type Safety
Use typed messages throughout:
import { UIMessage } from 'ai';
// Define custom metadata schema
const metadataSchema = z.object({
model: z.string(),
cost: z.number(),
timestamp: z.number(),
});
type MyMessage = UIMessage & {
metadata?: z.infer<typeof metadataSchema>;
};
// Type-safe message component
function Message({ message }: { message: MyMessage }) {
return (
<div>
{message.parts.map((part, i) => {
switch (part.type) {
case 'text':
return <p key={i}>{part.text}</p>;
case 'tool-weather':
return <WeatherCard key={i} {...part.output} />;
}
})}
</div>
);
}Optimistic Updates
Show messages immediately:
const { messages, sendMessage, status } = useChat({ /* ... */ });
const handleSend = async (text: string) => {
// Message appears immediately
sendMessage({ text });
// Clear input right away
setInput('');
};
// Show streaming indicator
{status === 'streaming' && (
<div className="animate-pulse">AI is typing...</div>
)}Rate Limiting
Prevent spam:
const [lastSent, setLastSent] = useState(0);
const MIN_INTERVAL = 1000; // 1 second
const handleSend = () => {
const now = Date.now();
if (now - lastSent < MIN_INTERVAL) {
toast.warning('Please wait before sending another message');
return;
}
setLastSent(now);
sendMessage({ text: input });
};Accessibility
<div role="log" aria-live="polite" aria-label="Chat messages">
{messages.map(m => (
<div
key={m.id}
role="article"
aria-label={`${m.role} message`}
>
{/* ... */}
</div>
))}
</div>
{status === 'streaming' && (
<div aria-live="assertive" className="sr-only">
AI is responding
</div>
)}Best Practices
1. Error boundaries: Wrap chat in error boundary 2. Loading states: Show clear streaming indicators 3. Retry logic: Allow retrying failed messages 4. Accessibility: Use proper ARIA attributes 5. Rate limiting: Prevent message spam 6. Monitoring: Log errors to observability service 7. Type safety: Use typed messages and parts
Tool Integration Reference
Using tools with useChat in AI SDK v6.
Tool Types
1. Server-side tools: Execute on server with execute function 2. Client-side automatic: Handle in onToolCall callback 3. Client-side interactive: Render UI for user interaction
Flow Overview
1. User sends message 2. Model generates tool calls 3. Server tools execute automatically 4. Client receives tool call parts 5. Client-side tools handled via onToolCall or UI 6. addToolOutput provides results 7. sendAutomaticallyWhen triggers next iteration
Server-Side Tools
// app/api/chat/route.ts
import { convertToModelMessages, streamText, UIMessage } from 'ai';
import { z } from 'zod';
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: 'openai/gpt-4o',
messages: await convertToModelMessages(messages), // v6: now async
tools: {
getWeather: {
description: 'Get weather for a city',
inputSchema: z.object({ city: z.string() }),
execute: async ({ city }) => {
// Server-side execution
const weather = await fetchWeatherAPI(city);
return { temperature: weather.temp, condition: weather.condition };
},
},
},
});
return result.toUIMessageStreamResponse();
}Client-Side Automatic Tools
'use client';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport, lastAssistantMessageIsCompleteWithToolCalls } from 'ai';
export default function Chat() {
const { messages, sendMessage, addToolOutput } = useChat({
transport: new DefaultChatTransport({ api: '/api/chat' }),
// Auto-submit when all tool results available
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
async onToolCall({ toolCall }) {
// Check for dynamic tools first (type narrowing)
if (toolCall.dynamic) return;
if (toolCall.toolName === 'getLocation') {
// No await - avoids potential deadlocks
addToolOutput({
tool: 'getLocation',
toolCallId: toolCall.toolCallId,
output: navigator.geolocation ? await getPosition() : 'Unknown',
});
}
},
});
// ...
}Interactive Tools (User Confirmation)
// Render tool parts with user interaction
{message.parts.map((part, index) => {
if (part.type === 'tool-askForConfirmation') {
const callId = part.toolCallId;
switch (part.state) {
case 'input-streaming':
return <div key={callId}>Loading...</div>;
case 'input-available':
return (
<div key={callId}>
<p>{part.input.message}</p>
<button onClick={() => addToolOutput({
tool: 'askForConfirmation',
toolCallId: callId,
output: 'Confirmed',
})}>
Yes
</button>
<button onClick={() => addToolOutput({
tool: 'askForConfirmation',
toolCallId: callId,
output: 'Denied',
})}>
No
</button>
</div>
);
case 'output-available':
return <div key={callId}>Result: {part.output}</div>;
case 'output-error':
return <div key={callId}>Error: {part.errorText}</div>;
}
}
})}Tool Part States
| State | Description |
|---|---|
input-streaming | Tool input being streamed |
input-available | Tool input complete, waiting for execution |
approval-requested | v6: Tool requires user approval before execution |
output-available | Tool execution complete with output |
output-error | Tool execution failed |
State Transitions
input-streaming → input-available → [approval-requested →] output-available
↘ ↗
→ output-error ←───────Standard Flow (no approval): input-streaming → input-available → output-available (or output-error)
With `needsApproval: true`: input-streaming → input-available → approval-requested → output-available (after approval)
Note: There is noapproval-respondedstate. After callingaddToolApprovalResponse(), the tool transitions directly tooutput-availableoroutput-error.
Error Handling
async onToolCall({ toolCall }) {
if (toolCall.dynamic) return;
if (toolCall.toolName === 'getWeather') {
try {
const weather = await fetchWeather(toolCall.input.city);
addToolOutput({
tool: 'getWeather',
toolCallId: toolCall.toolCallId,
output: weather,
});
} catch (err) {
addToolOutput({
tool: 'getWeather',
toolCallId: toolCall.toolCallId,
state: 'output-error',
errorText: 'Unable to get weather',
});
}
}
}Dynamic Tools (MCP, Runtime)
{message.parts.map((part, index) => {
switch (part.type) {
// Static tools with specific types
case 'tool-getWeather':
return <WeatherDisplay part={part} />;
// Dynamic tools use generic type
case 'dynamic-tool':
return (
<div key={index}>
<h4>Tool: {part.toolName}</h4>
{part.state === 'output-available' && (
<pre>{JSON.stringify(part.output, null, 2)}</pre>
)}
</div>
);
}
})}Tool Approval (v6)
Require user approval before tool execution:
Server Configuration
// app/api/chat/route.ts
const result = streamText({
model: 'openai/gpt-4o',
messages: await convertToModelMessages(messages),
tools: {
deleteFile: {
description: 'Delete a file',
inputSchema: z.object({ path: z.string() }),
needsApproval: true, // Requires user approval
execute: async ({ path }) => {
await fs.unlink(path);
return { deleted: path };
},
},
},
});Client Approval Handling
'use client';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
export default function Chat() {
const { messages, addToolApprovalResponse } = useChat({
transport: new DefaultChatTransport({ api: '/api/chat' }),
});
return (
<>
{messages.map(message => (
<div key={message.id}>
{message.parts.map((part, index) => {
if (part.type === 'tool-deleteFile') {
if (part.state === 'approval-requested') {
return (
<div key={index}>
<p>Delete {part.input.path}?</p>
<button onClick={() => addToolApprovalResponse({
id: part.approval.id,
approved: true,
})}>
Approve
</button>
<button onClick={() => addToolApprovalResponse({
id: part.approval.id,
approved: false,
})}>
Deny
</button>
</div>
);
}
if (part.state === 'output-available') {
return <div key={index}>Deleted: {part.output.deleted}</div>;
}
}
return null;
})}
</div>
))}
</>
);
}Multi-Step Server-Side Tools
// app/api/chat/route.ts
import { stepCountIs } from 'ai';
const result = streamText({
model: 'openai/gpt-4o',
messages: await convertToModelMessages(messages), // v6: now async
tools: {
search: { /* ... execute function ... */ },
analyze: { /* ... execute function ... */ },
},
stopWhen: stepCountIs(5), // Allow up to 5 steps
});Step Boundaries
{message.parts.map((part, index) => {
switch (part.type) {
case 'step-start':
// Show step boundaries
return index > 0 ? <hr key={index} /> : null;
case 'text':
return <span key={index}>{part.text}</span>;
case 'tool-getWeather':
// ...
}
})}Server Error Handling
// app/api/chat/route.ts
function errorHandler(error: unknown) {
if (error instanceof Error) return error.message;
return JSON.stringify(error);
}
return result.toUIMessageStreamResponse({
onError: errorHandler,
});Best Practices
1. Check dynamic first: Always check toolCall.dynamic before type narrowing 2. No await on addToolOutput: Prevents potential deadlocks 3. Handle all states: Cover all tool part states including approval-requested 4. Error gracefully: Use output-error state for failures 5. Use sendAutomaticallyWhen: Simplifies multi-step flows 6. Use needsApproval: For destructive or sensitive operations
useChat Fundamentals
Complete reference for the useChat hook API, state management, and configuration patterns.
Core API
Basic Setup
'use client';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import { useState } from 'react';
export default function Chat() {
const { messages, sendMessage, status } = useChat({
transport: new DefaultChatTransport({
api: '/api/chat',
}),
});
const [input, setInput] = useState('');
return (
<>
{messages.map(message => (
<div key={message.id}>
{message.role === 'user' ? 'User: ' : 'AI: '}
{message.parts.map((part, index) =>
part.type === 'text' ? <span key={index}>{part.text}</span> : null,
)}
</div>
))}
<form
onSubmit={e => {
e.preventDefault();
if (input.trim()) {
sendMessage({ text: input });
setInput('');
}
}}
>
<input
value={input}
onChange={e => setInput(e.target.value)}
disabled={status !== 'ready'}
placeholder="Say something..."
/>
<button type="submit" disabled={status !== 'ready'}>
Submit
</button>
</form>
</>
);
}Hook Return Values
const {
// Message state
messages, // UIMessage[] - Current conversation
setMessages, // (messages: UIMessage[]) => void - Update messages
// Input helpers (optional - can use manual state)
sendMessage, // (message: { text: string; files?: FileList }) => void
// Status tracking
status, // 'ready' | 'submitted' | 'streaming' | 'error'
// Control methods
stop, // () => void - Abort current request
regenerate, // () => void - Regenerate last response
reload, // () => void - Retry after error
// Error state
error, // Error | undefined
// Tool handling
addToolOutput, // Add tool execution results
addToolApprovalResponse, // v6: Respond to tool approval requests
} = useChat({
transport: new DefaultChatTransport({
api: '/api/chat',
}),
});Status Management
Status States
// Status values and their meanings:
const statusInfo = {
'ready': 'Idle - can accept new messages',
'submitted': 'Request sent - awaiting stream start',
'streaming': 'Actively receiving response chunks',
'error': 'Request failed - see error object',
};
// Using status for UI control
const { status, stop } = useChat();
return (
<>
{/* Show loading spinner */}
{(status === 'submitted' || status === 'streaming') && <Spinner />}
{/* Show stop button */}
{(status === 'submitted' || status === 'streaming') && (
<button onClick={() => stop()}>Stop</button>
)}
{/* Disable input during processing */}
<input disabled={status !== 'ready'} />
</>
);Cancellation and Regeneration
const { stop, regenerate, status } = useChat();
return (
<>
{/* Stop current generation */}
<button
onClick={stop}
disabled={!(status === 'streaming' || status === 'submitted')}
>
Stop
</button>
{/* Regenerate last message */}
<button
onClick={regenerate}
disabled={!(status === 'ready' || status === 'error')}
>
Regenerate
</button>
</>
);Message State Management
Direct Message Manipulation
const { messages, setMessages } = useChat();
// Delete a message
const handleDelete = (id: string) => {
setMessages(messages.filter(message => message.id !== id));
};
// Edit a message
const handleEdit = (id: string, newText: string) => {
setMessages(messages.map(message =>
message.id === id
? {
...message,
parts: [{ type: 'text', text: newText }]
}
: message
));
};
return (
<>
{messages.map(message => (
<div key={message.id}>
{message.parts.map((part, index) =>
part.type === 'text' ? <span key={index}>{part.text}</span> : null,
)}
<button onClick={() => handleDelete(message.id)}>Delete</button>
</div>
))}
</>
);Initial Messages
// Load existing conversation
const { messages } = useChat({
id: chatId,
messages: initialMessages, // UIMessage[]
transport: new DefaultChatTransport({
api: '/api/chat',
}),
});Event Callbacks
onFinish Callback
const { messages } = useChat({
transport: new DefaultChatTransport({
api: '/api/chat',
}),
onFinish: ({ message, messages, isAbort, isDisconnect, isError }) => {
// message: The new assistant message
// messages: All messages including the new one
// isAbort: User called stop()
// isDisconnect: Network disconnection
// isError: Error occurred
if (!isError && !isAbort) {
console.log('Generation completed successfully');
// Save to database, update analytics, etc.
}
},
});onError Callback
const { messages } = useChat({
transport: new DefaultChatTransport({
api: '/api/chat',
}),
onError: (error) => {
console.error('Chat error:', error);
// Show toast notification
// Log to error tracking service
// Custom error handling
},
});onData Callback
const { messages } = useChat({
transport: new DefaultChatTransport({
api: '/api/chat',
}),
onData: (data) => {
console.log('Received data part:', data);
// Handle different data types
if (data.type === 'data-notification') {
showToast(data.data.message);
}
// Can abort by throwing error
if (data.type === 'data-error') {
throw new Error('Aborting due to error data');
}
},
});Request Configuration
Hook-Level Configuration (All Requests)
const { messages, sendMessage } = useChat({
transport: new DefaultChatTransport({
api: '/api/custom-chat',
headers: {
Authorization: 'Bearer token',
'X-Custom-Header': 'value',
},
body: {
user_id: '123',
preferences: { theme: 'dark' },
},
credentials: 'same-origin',
}),
});Dynamic Hook-Level Configuration
const { messages, sendMessage } = useChat({
transport: new DefaultChatTransport({
api: '/api/chat',
// Functions are called on each request
headers: () => ({
Authorization: `Bearer ${getAuthToken()}`,
'X-User-ID': getCurrentUserId(),
}),
body: () => ({
sessionId: getCurrentSessionId(),
preferences: getUserPreferences(),
}),
credentials: () => 'include',
}),
});Note: For component state that changes over time, use useRef to store the current value and reference ref.current in your configuration function, or use request-level options.
Request-Level Configuration (Recommended)
// Pass options as second parameter to sendMessage
sendMessage(
{ text: input },
{
headers: {
Authorization: 'Bearer token123',
'X-Custom-Header': 'custom-value',
},
body: {
temperature: 0.7,
max_tokens: 100,
user_id: '123',
},
metadata: {
userId: 'user123',
sessionId: 'session456',
},
},
);Best Practice: Request-level options take precedence over hook-level options and provide better flexibility.
Custom Body Fields Per Request
// Client
sendMessage(
{ text: input },
{
body: {
customKey: 'customValue',
temperature: 0.8,
},
},
);
// Server - retrieve custom fields
export async function POST(req: Request) {
const { messages, customKey, temperature } = await req.json();
const result = streamText({
model: 'openai/gpt-4o',
messages: await convertToModelMessages(messages),
temperature,
});
return result.toUIMessageStreamResponse();
}Throttling UI Updates
const { messages } = useChat({
transport: new DefaultChatTransport({
api: '/api/chat',
}),
// Throttle updates to 50ms (React only)
// Note: Still experimental in v6
experimental_throttle: 50,
});Effect: Reduces render frequency during streaming. Default is to render on every chunk.
Transport Options
const { messages } = useChat({
transport: new DefaultChatTransport({
api: '/api/chat',
// Request configuration
headers: { Authorization: 'Bearer token' },
body: { customField: 'value' },
credentials: 'include',
// Stream resumption
resume: true,
}),
});Error State Handling
Display Error Message
const { messages, error, reload } = useChat();
return (
<div>
{messages.map(m => (
<div key={m.id}>
{m.role}:{' '}
{m.parts.map((part, index) =>
part.type === 'text' ? <span key={index}>{part.text}</span> : null,
)}
</div>
))}
{error && (
<>
<div>An error occurred.</div>
<button onClick={() => reload()}>Retry</button>
</>
)}
<form onSubmit={handleSubmit}>
<input disabled={error != null} />
</form>
</div>
);Best Practice: Show generic error messages to avoid leaking server information.
Custom Error Handling with Message Replacement
const { sendMessage, error, messages, setMessages } = useChat();
function customSubmit(event: React.FormEvent) {
event.preventDefault();
if (error != null) {
// Remove failed message before retry
setMessages(messages.slice(0, -1));
}
sendMessage({ text: input });
setInput('');
}File Attachments
Using FileList
const { messages, sendMessage, status } = useChat();
const [input, setInput] = useState('');
const [files, setFiles] = useState<FileList | undefined>();
const fileInputRef = useRef<HTMLInputElement>(null);
return (
<>
<form
onSubmit={e => {
e.preventDefault();
if (input.trim()) {
sendMessage({ text: input, files });
setInput('');
setFiles(undefined);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
}}
>
<input
type="file"
onChange={e => {
if (e.target.files) {
setFiles(e.target.files);
}
}}
multiple
ref={fileInputRef}
/>
<input
value={input}
onChange={e => setInput(e.target.value)}
disabled={status !== 'ready'}
/>
</form>
{/* Render file parts */}
{messages.map(message => (
<div key={message.id}>
{message.parts.map((part, index) => {
if (part.type === 'text') {
return <span key={index}>{part.text}</span>;
}
if (part.type === 'file' && part.mediaType?.startsWith('image/')) {
return <img key={index} src={part.url} alt={part.filename} />;
}
return null;
})}
</div>
))}
</>
);Note: Only image/* and text/* content types are automatically converted to multi-modal content parts.
Using File Objects
import { FileUIPart } from 'ai';
const [files] = useState<FileUIPart[]>([
{
type: 'file',
filename: 'earth.png',
mediaType: 'image/png',
url: 'https://example.com/earth.png',
},
{
type: 'file',
filename: 'data.png',
mediaType: 'image/png',
url: 'data:image/png;base64,iVBORw0KGgo...',
},
]);
sendMessage({ text: input, files });Type Safety
Type Inference for Tools
import { InferUITools, ToolSet } from 'ai';
import { z } from 'zod';
const tools = {
weather: {
description: 'Get weather',
inputSchema: z.object({
location: z.string(),
}),
execute: async ({ location }) => {
return `Weather in ${location}: sunny`;
},
},
} satisfies ToolSet;
type MyUITools = InferUITools<typeof tools>;
type MyUIMessage = UIMessage<never, UIDataTypes, MyUITools>;
// Use with useChat
const { messages } = useChat<MyUIMessage>();Common Patterns
Loading State with Disable
const { status, sendMessage } = useChat();
const isLoading = status === 'submitted' || status === 'streaming';
return (
<>
{isLoading && <Spinner />}
<button disabled={isLoading}>Send</button>
</>
);Conditional Rendering Based on Role
{messages.map(message => (
<div key={message.id} className={message.role}>
{message.role === 'user' && <UserAvatar />}
{message.role === 'assistant' && <BotAvatar />}
{message.parts.map((part, index) =>
part.type === 'text' ? part.text : null,
)}
</div>
))}Auto-scroll to Bottom
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
return (
<>
{messages.map(m => <MessageBubble key={m.id} message={m} />)}
<div ref={messagesEndRef} />
</>
);Related skills
FAQ
Which AI SDK UI hook should I use?
Use useChat for multi-turn conversations with tools, useObject for typed/structured streaming data, and useCompletion for single-turn text generation.
What backends does it integrate with?
It integrates with Next.js, Node, Fastify, and Nest backends, and the server example uses streamText with toUIMessageStreamResponse in a Next.js App Router route.