Generative UI via tool parts
Generative UI with the Vercel AI SDK, rendering a model's tool outputs as typed React components through a chat message's tool parts.
In the last lesson the model called getInvoiceById, which returns { id, customerName, total, dueDate, status }.
You never decided what the user sees when that result arrives.
Your chat loop renders only text parts, so the tool result either shows nothing or dumps raw JSON into a bubble.
What it should produce is an invoice card with a status badge and a “send reminder” button.
That is generative UI: by choosing which tool to call, the model chooses which React component the user sees, and your client renders that tool’s output as the component. This lesson shows you how to render any tool’s output as a typed, purpose-built component through every state of its lifecycle.
Choose AI SDK UI, not ai/rsc
Section titled “Choose AI SDK UI, not ai/rsc”The AI SDK offers two ways to build generative UI, and you build on only one.
AI SDK UI is the useChat hook and the typed tool parts you already met: the model emits tool parts and your client renders them.
The SDK recommends it for production, and it reuses the UIMessage, useChat, and route handler you already built, so there’s no new infrastructure.
The other option, ai/rsc , is experimental and its API shifts between releases; this course names it only so you recognize it in the docs.
- Streams typed
tool-<name>parts on theUIMessage; the client switches onpart.typeto pick the component. - Rendering stays on the client, where React 19 and your component tree live.
- Stable, production-recommended API.
- Streams server-rendered React components straight from the model call via
streamUI/useUIState/useAIState. - Marked experimental in the docs; its API changes between releases.
Rendering a tool part: one more case in the switch
Section titled “Rendering a tool part: one more case in the switch”You already built the rendering machinery. Every assistant message is { id, role, parts }, and the chat surface walks that array with message.parts.map((part) => ...) and switches on part.type. So far it has one case, text. Generative UI is the same switch with one more branch.
That branch comes from the tool call. When the model calls a tool, the assistant message grows a part typed tool- plus the tool’s key in your tools object, so the tool keyed getInvoiceById produces a tool-getInvoiceById part. The model’s call creates the part; your client decides what to render for it.
const MessagePart = ({ part }: { part: UIMessagePart }) => { switch (part.type) { case 'text': return <span>{part.text}</span>; default: return null; }};The switch you already shipped. One case for text, and a default that renders nothing for unrecognized part types.
const MessagePart = ({ part }: { part: UIMessagePart }) => { switch (part.type) { case 'text': return <span>{part.text}</span>; case 'tool-getInvoiceById': return <InvoiceCard {...part.output} />; default: return null; }};One added branch. A tool-getInvoiceById part renders an <InvoiceCard /> from its output. The model’s tool choice picked the component; the switch routes it.
The default: return null is load-bearing. As you add tools, older clients will meet part types they don’t recognize, and a switch with no fallback crashes mid-chat on an unknown part.type. The default makes an unknown part render nothing instead of throwing.
That snippet reads part.output, which exists only once the tool finishes; a tool still running has no output yet. The next section renders those in-between states.
Rendering a tool part’s four states
Section titled “Rendering a tool part’s four states”A tool-getInvoiceById part moves through the four states from the last lesson, and you render each: input-streaming while argument tokens arrive, input-available once they parse and execute runs server-side, output-available when the result returns, and output-error on failure. Render only the last and a slow tool feels frozen, the user clicks and nothing changes for two seconds; render the whole journey instead.
While the tool runs, render a tool-specific skeleton, a greyed-out placeholder shaped like the content that’s loading, not a generic spinner. A chat with five tools shows five different skeletons: an invoice-shaped one while an invoice loads, a chart-shaped one while a chart loads. The user sees an invoice loading, not something loading.
Step through the switch over part.state, one state at a time.
case 'tool-getInvoiceById': { switch (part.state) { case 'input-streaming': return null; case 'input-available': return <InvoiceCard.Skeleton />; case 'output-available': return <InvoiceCard {...part.output} />; case 'output-error': return <ToolError message={part.errorText} />; }}Argument tokens are still arriving, so there’s nothing concrete to show. Most surfaces render nothing, or fall through to the next state’s skeleton.
case 'tool-getInvoiceById': { switch (part.state) { case 'input-streaming': return null; case 'input-available': return <InvoiceCard.Skeleton />; case 'output-available': return <InvoiceCard {...part.output} />; case 'output-error': return <ToolError message={part.errorText} />; }}The arguments are parsed and execute is running on the server. This is where the tool-specific skeleton goes: the user sees the shape of an invoice forming, not a generic spinner.
case 'tool-getInvoiceById': { switch (part.state) { case 'input-streaming': return null; case 'input-available': return <InvoiceCard.Skeleton />; case 'output-available': return <InvoiceCard {...part.output} />; case 'output-error': return <ToolError message={part.errorText} />; }}execute has returned and part.output is populated. Spreading it into <InvoiceCard /> works because the tool’s output shape is the card’s props, which the next sections make true at the type level.
case 'tool-getInvoiceById': { switch (part.state) { case 'input-streaming': return null; case 'input-available': return <InvoiceCard.Skeleton />; case 'output-available': return <InvoiceCard {...part.output} />; case 'output-error': return <ToolError message={part.errorText} />; }}The tool failed. The error lives on part.errorText, already sanitized by the SDK into a safe string, so render it through your own error component, never a raw error.message.
The same journey crosses the server boundary. Scrub through it and watch one part change state.
The model emits a tool call; the part appears, arguments still streaming in.
Arguments parse, so execute runs server-side, the only step on the server.
The result returns and output is populated, so the real card renders.
execute threw, leaving a sanitized errorText for the error component.
For a genuinely long-running tool, execute can yield partial results so part.output fills in progressively, but the default is to return once with a small output schema.
Typing the chat end-to-end with InferUITools
Section titled “Typing the chat end-to-end with InferUITools”The snippets above spread part.output into <InvoiceCard /> as if its shape were known, but to the client it’s still unknown, the outputSchema you named last lesson was never wired up. Wire it now and part.output autocompletes instead of forcing a cast.
You type the whole chat outward from the tool definitions, in three steps.
import type { InferUITools, UIDataTypes, UIMessage } from 'ai';
import { tools } from '@/lib/llm/tools';
type MyUITools = InferUITools<typeof tools>;type MyUIMessage = UIMessage<never, UIDataTypes, MyUITools>;InferUITools reads each tool’s Zod schemas and infers its { input, output } shapes, turning the tools object into a typed map of what every tool takes and returns.
import type { InferUITools, UIDataTypes, UIMessage } from 'ai';
import { tools } from '@/lib/llm/tools';
type MyUITools = InferUITools<typeof tools>;type MyUIMessage = UIMessage<never, UIDataTypes, MyUITools>;Stamp those types onto the message. UIMessage’s generics are <metadata, dataParts, tools>: never for metadata (this chat carries none), the UIDataTypes default for data parts, and your inferred tools last. This type now knows every tool part’s shape.
import type { InferUITools, UIDataTypes, UIMessage } from 'ai';
import { tools } from '@/lib/llm/tools';
type MyUITools = InferUITools<typeof tools>;type MyUIMessage = UIMessage<never, UIDataTypes, MyUITools>;Both helpers come from ai; tools is imported from your registry module, and the next section covers why that import has to exist.
Pass MyUIMessage to useChat<MyUIMessage>({ ... }) on the client and to the message-stream helper on the server, and both ends speak the same typed message. The payoff lands in the switch you wrote earlier: inside the output-available branch of case 'tool-getInvoiceById', part.output autocompletes as the exact shape the tool returns.
const { messages } = useChat<MyUIMessage>({ /* ... */ });
const overdue = part.output.status === 'overdue';return <InvoiceCard {...part.output} highlight={overdue} />;Write outputSchema once on the tool, and InferUITools carries it all the way to the component’s prop type. The third generic, UIDataTypes, types non-tool message parts; you’re using its default, which is a topic for another lesson.
Why tools lives in its own module (lib/llm/tools.ts)
Section titled “Why tools lives in its own module (lib/llm/tools.ts)”InferUITools<typeof tools> reads a single tools object, so that object must live in a module both ends import. Define a tool inline in the route handler and there’s nothing for the client to import a type from: the message type never learns the tool, and part.output collapses to unknown. No error, just lost autocomplete and an invitation to cast. The last lesson kept the tool inline to keep the snippet legible; this is the extraction it was heading toward.
So tools get their own file, lib/llm/tools.ts, alongside the conventions you already follow: lib/llm/models.ts for model handles and lib/llm/prompts.ts for system prompts. The route handler imports tools to run; the client imports MyUIMessage to type the chat.
Directorysrc/
Directorylib/
Directoryllm/
- models.ts model handles (
smartModel,fastModel) - prompts.ts system prompts
- tools.ts the tool registry — and
MyUIMessage
- models.ts model handles (
The two tabs show the same tool defined the way that breaks typing and the way that preserves it.
export const POST = authedRoute('member', chatRequestSchema, async ({ messages }) => { const result = streamText({ model: smartModel, messages: convertToModelMessages(messages), // Defined here, the client can't import this — part.output stays `unknown`. tools: { getInvoiceById: tool({ /* ... */ }) }, }); return result.toUIMessageStreamResponse();});No exported tools object, so InferUITools has nothing to read.
export const tools = { getInvoiceById };export type MyUIMessage = UIMessage<never, UIDataTypes, InferUITools<typeof tools>>;
// app/api/chat/route.tsimport { tools } from '@/lib/llm/tools';// streamText({ model: smartModel, tools, ... })One exported tools object: the handler imports it to run, the client imports MyUIMessage to type the chat.
Design the tool output as the component’s props
Section titled “Design the tool output as the component’s props”A tool’s outputSchema and its React component’s props are one shape, designed together.
A getInvoiceById tool that returns { id, customerName, total, dueDate, status } feeds <InvoiceCard {...part.output} /> directly, no adapter in between.
Write the schema once and it serves as the component’s prop type.
The mistake is returning whatever the data source hands you, like a raw Drizzle row, and reshaping it inside the component.
The moment your card computes row.amountCents / 100 or row.customer.displayName ?? row.customer.email, it knows the column is in cents and the customer is a nested join.
Change the query and you break a render, and that coupling stays invisible until it does.
So shape the output in the tool: the schema you write there is the component’s prop type, so emit the shape the component wants. This is the same “project, don’t dump” discipline from the last lesson, now aimed at the component’s contract instead of the token budget.
// outputSchema: the raw DB row, handed straight to the componentconst InvoiceCard = ({ row }: { row: InvoiceRow }) => { const total = formatCurrency(row.amountCents / 100); const name = row.customer.displayName ?? row.customer.email; return <Card>{name} — {total} — {row.status}</Card>;};Coupled to the data layer. A query change breaks the render.
// outputSchema: { customerName, total, status, ... } — already the card's propsconst InvoiceCard = ({ customerName, total, status }: InvoiceCardProps) => ( <Card>{customerName} — {total} — {status}</Card>);
<InvoiceCard {...part.output} />;Shaped in the tool. The component takes presentation-ready props, with no idea a database exists.
Data selection, shaping, and authorization live in the tool; presentation lives in the component. The drill below sorts a handful of real transformations into the two bins.
Each of these transformations has a right home. Sort each one into the layer where it belongs. Drag each item into the bucket it belongs to, then press Check.
amountCents into a formatted currency stringdueDate as a relative time like “in 3 days”Interleaved text and tool parts
Section titled “Interleaved text and tool parts”A single assistant message can hold text parts and multiple tool parts, in the order the model emitted them.
The model narrates between its renders: “Here’s the invoice you asked about”, then the invoice card, then “want me to send a reminder?”
Since parts.map walks the array in order, the rule is to render each part where it sits, never collecting the tool renders into a stack at the bottom of the bubble.
Here’s the invoice you asked about:
It’s 4 days overdue — want me to send a reminder?
One assistant message in parts order: text, an invoice card, more text, then a second tool render.
Guarding destructive actions: propose, then confirm
Section titled “Guarding destructive actions: propose, then confirm”Every tool so far only reads. The hard case is a tool that acts: sending an invoice, deleting a row, charging a card.
The model must never trigger a destructive action directly. “Occasionally wrong” is fine for a sentence and catastrophic for a DELETE: on a hallucinated argument, an unguarded tool sends the wrong invoice or deletes the wrong row, and at scale that’s a when, not an if.
The fix is to split one destructive action into two tools with a human click between them:
proposeInvoiceSendis read-only. It returns a preview, the recipient, amount, and due date, which the client renders as a confirmation card with “Send” and “Cancel” buttons.confirmInvoiceSenddoes the work, and the client emits it only after the user clicks Send, continuing the conversation with a new message.
This deliberately breaks the agentic loop: control returns to the person before the next step can fire. The model can plan the send, but it cannot pull the trigger.
In code, the shape is two tool definitions plus a confirmation component. The preview tool carries an outputSchema for what the user is about to approve; the commit tool carries the real execute.
export const proposeInvoiceSend = tool({ description: 'Preview sending an invoice. Read-only — does not send.', inputSchema: z.object({ invoiceId: z.uuid() }), outputSchema: z.object({ recipient: z.string(), total: z.string(), dueDate: z.string() }), execute: async ({ invoiceId }) => getInvoiceSendPreview(invoiceId, session.orgId),});
export const confirmInvoiceSend = tool({ description: 'Send the invoice. Only call after the user confirms.', inputSchema: z.object({ invoiceId: z.uuid() }), execute: async ({ invoiceId }) => sendInvoice(invoiceId, session.orgId),});The confirmation card renders the buttons, and the client emits the confirm call only on the Send click:
case 'tool-proposeInvoiceSend': return ( <ConfirmCard preview={part.output}> <button onClick={() => sendMessage({ text: 'Yes, send it.' })}>Send</button> <button onClick={dismiss}>Cancel</button> </ConfirmCard> );The next chapter wires the end-to-end version with real preview data; here the shape is the point: two tools, with a human in the middle.
What stays the same: auth, audit, persistence
Section titled “What stays the same: auth, audit, persistence”Generative UI changed how tool results render, but not the seams underneath, because tool parts ride the same protocol as everything else.
- Authorization still happens inside each tool’s
execute, through the org-scope filter againstsession.orgIdfrom the last lesson. - Audit events still write per step in
onStepFinishand in aggregate inonFinish. - The Server Component shell still loads the conversation’s history and hands it to the client.
- Persistence still saves the full
UIMessage[]server-side, but that array now includes the tool parts and their typed outputs.
The handler return you already wrote saves messages for you:
return result.toUIMessageStreamResponse({ originalMessages: messages, onFinish: ({ messages }) => saveChat(chatId, messages),});The one thing to watch is persisting a lossy shape. Save only the text, or drop the tool parts to “save space”, and the invoice card vanishes on the next mount, because the part that drove it was never saved. Persist the full UIMessage[], tool parts and all.
Check your understanding
Section titled “Check your understanding”The model is a router that picks which component renders, the tool’s output schema is that component’s prop contract, the client walks parts in order switching on type and state, and destructive work waits behind a human click.
You’re building a generative-UI chat surface for production in 2026. Which path does the AI SDK’s own documentation recommend?
tool-<name> parts and render them from useChat on the client.streamUI.useChat. It’s the production-recommended API and reuses the UIMessage, useChat, and route handler you already built. streamUI belongs to ai/rsc, which the docs mark experimental — and the two aren’t flavors of one feature, they’re separate architectures, which is why mixing them up is how you end up on the experimental one by accident.You define getInvoiceById inline inside the route handler instead of exporting it from lib/llm/tools.ts. The code compiles and the chat works. What did you quietly give up?
part.output widens to unknown — there’s no exported tools object InferUITools can read a shape from.lib/llm/.getInvoiceById.streamText — that’s exactly what makes it a trap. The loss is purely on the client’s types: InferUITools<typeof tools> needs a single exported tools object to infer from, and a tool trapped inside a route handler can’t be imported, so part.output collapses to unknown and you’re back to casting.Why split a “send invoice” action into a read-only proposeInvoiceSend tool and a separate confirmInvoiceSend tool?
execute.execute could absolutely both read and write. And the two tools are sequential, gated by the click, not parallel. The whole point is control: proposeInvoiceSend only previews, and confirmInvoiceSend fires only after the user clicks Send, so a hallucinated argument can never send anything on its own.In which tool-part state does a tool-specific skeleton like <InvoiceCard.Skeleton /> belong?
execute is running on the server, with no output yet.execute threw and the part carries a sanitized error message.input-available — the window where the tool is working server-side and there’s nothing concrete to show, so a shaped placeholder tells the user an invoice is loading, not just something. Once the result arrives (output-available) you render the real card, and a failure (output-error) renders your error component from part.errorText, never a loading skeleton.External resources
Section titled “External resources”The AI SDK UI guide for rendering tool parts as React components — the canonical reference for the pattern in this lesson.
Renders tool parts through every lifecycle state and walks the human-in-the-loop approval flow behind the propose/confirm pattern.
The reference for typing the chat end to end — how InferUITools derives each tool's input and output shapes for the UIMessage type.
An interactive Vercel Academy lesson that builds a tool end to end with the tool() helper, Zod schemas, and a rendered result.