Build a Generative UI Chatbot with AI Elements, Vercel AI SDK, and OpenRouter
Build a type-safe generative UI chatbot that can choose between streamed text and trusted React components using AI Elements, Vercel AI SDK, and free models through OpenRouter.
Most chatbots have one interface primitive: text.
Ask for campaign metrics and you get a paragraph. Ask for a comparison and you get a Markdown table. Ask for a trend and the model explains a chart that does not exist.
In this tutorial, we will build a chatbot that decides when to answer with streamed text and when to render a purpose-built React component. We will use AI Elements for the chat shell, Vercel AI SDK for streaming and typed tool calls, and OpenRouter as the model provider. We will start with OpenRouter's free model router.
User: Show campaign performance for the last 30 days.
┌──────────────────────────────────────────────────────┐
│ CAMPAIGN PERFORMANCE LAST 30 DAYS │
├──────────────────────────────────────────────────────┤
│ $18,430 1,243 $14.82 3.8× │
│ Spend Leads CPL ROAS │
├──────────────────────────────────────────────────────┤
│ Retargeting ████████████████████████ 5.1× │
│ Brand ████████████████████ 4.2× │
│ Lead Gen █████████████████ 3.6× │
└──────────────────────────────────────────────────────┘The key distinction is simple:
The LLM does not generate React. It selects an interface capability exposed by your application.
Generative UI as a typed interface protocol
┌────────────┐ tool call ┌────────────┐
│ LLM │ ─────────────────▶ │ TOOL │
└────────────┘ └─────┬──────┘
│ typed data
▼
┌────────────┐ typed UI part ┌────────────┐
│ REACT │ ◀──────────────── │ UI MESSAGE │
└────────────┘ └────────────┘The model expresses intent through a tool call. The application retrieves authoritative data. AI SDK streams the tool lifecycle as message parts. React maps those parts to components we wrote and control.
We are not executing model-generated JSX in the browser.
1. Create the project
npx create-next-app@latest generative-ui-demo
cd generative-ui-demo
npm install ai @ai-sdk/react @openrouter/ai-sdk-provider zod
npx ai-elements@latestFor this tutorial, we only need AI Elements' conversation, message, and prompt-input components.
app/
├── api/chat/route.ts
└── page.tsx
ai/tools.ts
components/generative-ui/
├── campaign-metrics.tsx
└── metrics-skeleton.tsx
lib/
├── ai.ts
└── campaign-data.ts2. Connect AI SDK to OpenRouter
Create lib/ai.ts:
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
export const openrouter = createOpenRouter({
apiKey: process.env.OPENROUTER_API_KEY,
});
export const model = openrouter(
process.env.OPENROUTER_MODEL ?? "openrouter/free"
);Configure .env.local:
OPENROUTER_API_KEY=your_openrouter_api_key
OPENROUTER_MODEL=openrouter/freeopenrouter/free lets OpenRouter choose from available free models while filtering for capabilities required by the request, including tool calling.
That is useful for development. It does not mean every free model has identical tool-use behaviour. In production, test your tools against specific models and pin or deliberately route the model.
3. Build the streaming route
Create app/api/chat/route.ts:
import {
convertToModelMessages,
createUIMessageStreamResponse,
streamText,
toUIMessageStream,
type UIMessage,
} from "ai";
import { model } from "@/lib/ai";
export async function POST(request: Request) {
const { messages }: { messages: UIMessage[] } =
await request.json();
const result = streamText({
model,
system: "You are a marketing analytics assistant.",
messages: await convertToModelMessages(messages),
});
return createUIMessageStreamResponse({
stream: toUIMessageStream({ stream: result.stream }),
});
}The browser is not receiving a simple { role, content } transcript. AI SDK UI messages contain parts. A message can contain text and typed tool parts in the same conversational turn.
useChat() → /api/chat → streamText() → UI message stream → useChat()That message model is what makes generative UI fit naturally into chat.
4. Build the chat shell with AI Elements
The current useChat architecture uses a transport and local input state:
"use client";
import { useState } from "react";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
export default function ChatPage() {
const [input, setInput] = useState("");
const { messages, sendMessage, status } = useChat({
transport: new DefaultChatTransport({ api: "/api/chat" }),
});
const handleSubmit = (message: { text: string }) => {
if (!message.text.trim()) return;
sendMessage({ text: message.text });
setInput("");
};
// Render Conversation, Message and PromptInput here.
}Older tutorials often destructure input, handleInputChange, and handleSubmit from useChat. Do not mix those examples into a current transport-based implementation.
At this point, we have a normal streaming chatbot. Now we give it an interface vocabulary.
5. Keep authoritative data outside the model
Create lib/campaign-data.ts:
export async function getCampaignMetrics(period: string) {
return {
period,
spend: 18430,
leads: 1243,
cpl: 14.82,
roas: 3.8,
campaigns: [
{ name: "Retargeting", roas: 5.1 },
{ name: "Brand", roas: 4.2 },
{ name: "Lead Gen India", roas: 3.6 },
],
};
}Do not ask the LLM to supply spend, leads, or roas. The model can manufacture those values.
The model should choose the query:
period = last_30_daysYour application should resolve it against Meta Ads, a CRM, PostgreSQL, an analytics warehouse, or another source of truth.
Let the model choose what data it needs. Let your application retrieve the data.
6. Create the generative UI tool
Create ai/tools.ts:
import { tool } from "ai";
import { z } from "zod";
import { getCampaignMetrics } from "@/lib/campaign-data";
const outputSchema = z.object({
period: z.string(),
spend: z.number(),
leads: z.number(),
cpl: z.number(),
roas: z.number(),
campaigns: z.array(z.object({
name: z.string(),
roas: z.number(),
})),
});
export const displayCampaignMetrics = tool({
description: "Display campaign performance metrics.",
inputSchema: z.object({
period: z.enum([
"last_7_days",
"last_30_days",
"last_90_days",
]),
}),
outputSchema,
execute: async ({ period }) => getCampaignMetrics(period),
});
export const tools = { displayCampaignMetrics };The schemas create two explicit boundaries:
MODEL
│
▼
[ INPUT SCHEMA ] period: enum
│
▼
TOOL EXECUTION getCampaignMetrics()
│
▼
[ OUTPUT SCHEMA ] spend, leads, campaigns
│
▼
REACTThe model controls a narrow, validated request. The application controls execution and data. The UI receives a predictable result.
7. Strongly type the UI messages
Update the route:
import {
type InferUITools,
type UIDataTypes,
type UIMessage,
isStepCount,
} from "ai";
import { tools } from "@/ai/tools";
export type ChatTools = InferUITools<typeof tools>;
export type ChatMessage = UIMessage<never, UIDataTypes, ChatTools>;
const result = streamText({
model,
system: `
You are a marketing analytics assistant.
Use displayCampaignMetrics when the user asks to
see or view campaign performance or metrics.
Use normal text for explanations and analysis.
`,
messages: await convertToModelMessages(messages),
tools,
stopWhen: isStepCount(5),
});InferUITools<typeof tools> derives UI-facing types from the real tool registry. Once the client uses useChat<ChatMessage>(), TypeScript understands:
tool-displayCampaignMetricsIt also understands the tool input and output. This is much stronger than receiving generic JSON and casting it with as CampaignData.
8. Render the React component
Your CampaignMetrics component is ordinary trusted React:
export function CampaignMetrics({ data }: { data: CampaignMetricsData }) {
const maxRoas = Math.max(...data.campaigns.map((c) => c.roas));
return (
<section className="my-3 overflow-hidden rounded-xl border">
<header className="flex justify-between border-b p-4">
<h2 className="font-semibold">Campaign Performance</h2>
<span>{data.period.replaceAll("_", " ")}</span>
</header>
<div className="grid grid-cols-4">
<Metric label="Spend" value={`$${data.spend.toLocaleString()}`} />
<Metric label="Leads" value={data.leads.toLocaleString()} />
<Metric label="CPL" value={`$${data.cpl.toFixed(2)}`} />
<Metric label="ROAS" value={`${data.roas.toFixed(1)}×`} />
</div>
{data.campaigns.map((campaign) => (
<div key={campaign.name} className="p-4">
<div className="flex justify-between">
<span>{campaign.name}</span>
<span>{campaign.roas.toFixed(1)}×</span>
</div>
<div className="h-2 bg-muted">
<div
className="h-full bg-foreground"
style={{ width: `${campaign.roas / maxRoas * 100}%` }}
/>
</div>
</div>
))}
</section>
);
}We deliberately avoid a charting dependency. The tutorial is about the interface protocol, not chart configuration.
9. Map message.parts to UI
Type the hook:
const { messages, sendMessage, status } = useChat<ChatMessage>({
transport: new DefaultChatTransport({ api: "/api/chat" }),
});Then render each part:
{message.parts.map((part, index) => {
switch (part.type) {
case "text":
return <MessageResponse key={index}>{part.text}</MessageResponse>;
case "tool-displayCampaignMetrics": {
const callId = part.toolCallId;
switch (part.state) {
case "input-streaming":
case "input-available":
return <MetricsSkeleton key={callId} />;
case "output-available":
return (
<CampaignMetrics key={callId} data={part.output} />
);
case "output-error":
return <div key={callId}>{part.errorText}</div>;
}
}
default:
return null;
}
})}This is the generative UI moment:
"Show campaign performance"
│
▼
OPENROUTER MODEL
│ chooses tool
▼
displayCampaignMetrics({ period: "last_30_days" })
│
▼
getCampaignMetrics()
│ typed data
▼
tool-displayCampaignMetrics
│ output-available
▼
<CampaignMetrics />The model selected the interface without generating the interface.
10. Generative UI is a state machine
A tool part moves through a lifecycle:
input-streaming
│ model constructs input
▼
input-available
│ valid request exists
▼
output-available
OR
output-errorThis means the interface can become domain-aware before data arrives. A generic spinner says, "The AI is thinking." A MetricsSkeleton says, "Campaign metrics are loading."
That is a better product experience because the interface already knows which capability is executing.
11. Your tool registry becomes a UI vocabulary
Add another tool named compareCampaigns and map its typed part to a CampaignComparison component.
displayCampaignMetrics ──▶ <CampaignMetrics />
compareCampaigns ──▶ <CampaignComparison />
displayLeadTable ──▶ <LeadTable />
requestApproval ──▶ <ApprovalCard />
createReport ──▶ <ReportPreview />The model is not choosing arbitrary HTML. It is choosing from a bounded set of product capabilities.
Your tool registry is also an interface vocabulary.
Use the system prompt to define when each interface is appropriate:
const systemPrompt = `
Use displayCampaignMetrics when the user asks to see
campaign performance.
Use compareCampaigns for side-by-side comparisons.
Use normal text for explanations and recommendations.
If the user asks to see data and explain it, use the
appropriate tool and then provide concise analysis.
`;Now the assistant can make different interface decisions:
"Show campaign performance" → <CampaignMetrics />
"Compare the campaigns" → <CampaignComparison />
"Why is CPL increasing?" → streamed text
"Show performance and explain it" → component + streamed textGenerative UI is not "JSON instead of text." A conversational turn can combine tool-driven interfaces with streamed language.
12. Where AI Elements fits
AI Elements owns the recurring chat shell. Your application owns domain interfaces.
┌──────────────────────────────────────────────┐
│ CHAT SHELL │
│ Conversation, Message, PromptInput │ ◀ AI Elements
├──────────────────────────────────────────────┤
│ DOMAIN INTERFACES │
│ CampaignMetrics, ApprovalCard, LeadTable │ ◀ Your app
└──────────────────────────────────────────────┘Do not wrap CampaignMetrics in a generic tool visualization merely because a tool produced it. A tool execution surface and a product interface are different things.
Use generic tool UI when the user should inspect tool execution. Render the domain component directly when the tool result is the product experience.
13. Test free models against tool-use behaviour
Our development configuration uses:
openrouter("openrouter/free")But this application depends on tool-use reliability, not merely text generation. Build a small evaluation set:
PROMPT EXPECTED
Show campaign performance displayCampaignMetrics
Show me the last 90 days displayCampaignMetrics
Compare the campaigns compareCampaigns
Why is retargeting stronger? text
Show performance and explain the problem tool + textMeasure tool selection accuracy, schema validity, unnecessary tool-call rate, multi-step completion rate, latency, and cost.
A model can score well on general benchmarks and still be poor for this interface if it selects a tool for every analytical question.
For production, configure a tested model:
OPENROUTER_MODEL=your-tested-tool-capable-modelThe application architecture stays the same.
From chatbot to conversational control surface
The same pattern works for an operations assistant:
"Show failed automations" → <FailureTable />
"Retry the HubSpot sync" → <ApprovalCard />
"Why did it fail?" → streamed explanation
"Show the execution trace" → <TraceTimeline />Or a CRM assistant:
"Find cold leads" → <LeadTable />
"Create a reactivation campaign" → <CampaignPlan />
"Launch it" → <ApprovalCard />The production architecture becomes:
┌─────────────┐
│ LLM │
└──────┬──────┘
▼
┌───────────────────┐
│ TOOL REGISTRY │
└─────────┬─────────┘
▼
┌───────────────────┐
│ APP SERVICES │
│ CRM / APIs / DB │
└─────────┬─────────┘
│ typed results
▼
┌───────────────────┐
│ UI MESSAGE STREAM │
└─────────┬─────────┘
▼
┌───────────────────┐
│ COMPONENT REGISTRY│
└───────────────────┘At that point, the chatbot is no longer a text box attached to an LLM. It is a conversational control surface for your application.
Design principles to keep
The model selects the UI. It does not own the UI. Keep React components in application code and expose bounded capabilities through tools.
Tools are interface contracts. Input schemas constrain what the model can request. Tool execution connects to application services. Typed outputs define what the interface can render.
Keep authoritative data outside the model. Let the model formulate the request, then retrieve data from the source of truth.
Treat tool lifecycle as UI state. input-streaming, input-available, output-available, and output-error are opportunities for domain-specific loading and failure experiences.
Evaluate models against your tool suite. Free routing is useful for experimentation. Production model selection should test the behaviours your interface depends on.
Conclusion
Generative UI is often described as AI dynamically generating interfaces. A more useful implementation model is:
Generative UI is a typed interface protocol between an LLM, application tools, and React.
The LLM expresses intent through a tool call. Your application executes trusted code and retrieves authoritative data. AI SDK streams the lifecycle as typed UI message parts. React maps those parts to components you designed.
AI Elements gives the application a conversational shell. Vercel AI SDK provides the message and tool protocol. OpenRouter lets the same application route across models, including free models while prototyping.
The result is not an AI that writes your frontend.
It is an AI that knows which part of your frontend to use.

Saif Khan is the Principal Consultant at Insurge, where he designs AI automation systems, digital products, and operational infrastructure for service businesses and growth teams. With more than a decade of experience across digital marketing, analytics, marketing technology, and software implementation, his work sits at the intersection of business operations and technical systems. He focuses on turning repetitive workflows, fragmented data, and product ideas into practical automation systems and software that teams can actually operate and scale.

