Insurge
AI Automation

Build a Generative UI Chatbot with AI Elements, Vercel AI SDK, and OpenRouter

By Saif Khan · 2026.07.06

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@latest

For 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.ts

2. 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/free

openrouter/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_days

Your 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
  │
  ▼
REACT

The 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-displayCampaignMetrics

It 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-error

This 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 text

Generative 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 + text

Measure 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-model

The 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.

Author
Saif Khan, Principal Consultant at Insurge

Saif Khan

Principal Consultant, AI Systems & Automation

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.