Dev & EngARTICLE

Generative UI with AI SDK: from text chat to streamed React components in Next.js

A step-by-step guide in Next.js App Router to go from a text-only chat to an assistant that decides to call tools and returns typed React components rendered in the stream.

Generative UI with AI SDK: from text chat to streamed React components in Next.js
Image: Carina Ferreira

Chat that only spits out text is already the bare minimum. What's interesting now is the LLM deciding, mid-conversation, that the best answer to "what's the weather in SF?" isn't a paragraph, it's a rendered weather card. This is what Vercel calls generative UI: connecting a tool's result to a React component and sending that component through the same response stream.

The path I'd follow with the AI SDK (docs at v7, with the patterns introduced in 5.0) has four pieces: the useChat hook on the client, an API route with streamText, tools typed with Zod, and components that react to each tool's state. I'll build this from a bare chat up to an assistant that switches cards according to intent.

The text chat, no frills

The starting point is useChat from @ai-sdk/react. Notice a shift in mindset compared to older versions: input is now your own state, managed with useState, and you dispatch messages with sendMessage instead of a magic handleSubmit.

tsx
'use client';
import { useChat } from '@ai-sdk/react';
import { useState } from 'react';

export default function Page() {
  const [input, setInput] = useState('');
  const { messages, sendMessage } = useChat();

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    sendMessage({ text: input });
    setInput('');
  };

  return (
    <div>
      {messages.map(message => (
        <div key={message.id}>
          <div>{message.role === 'user' ? 'User: ' : 'AI: '}</div>
          <div>
            {message.parts.map((part, i) =>
              part.type === 'text' ? <span key={i}>{part.text}</span> : null,
            )}
          </div>
        </div>
      ))}
      <form onSubmit={handleSubmit}>
        <input value={input} onChange={e => setInput(e.target.value)} placeholder="Type a message..." />
        <button type="submit">Send</button>
      </form>
    </div>
  );
}

The detail that changes everything from here on is message.parts. A message is no longer a string: it's an array of parts, each with a type. Text is just one of the possible types, and that's where components come in.

On the server side, the API route uses streamText and returns a stream of UI messages:

ts
import {
  convertToModelMessages,
  createUIMessageStreamResponse,
  isStepCount,
  streamText,
  toUIMessageStream,
  UIMessage,
} from 'ai';

export async function POST(request: Request) {
  const { messages }: { messages: UIMessage[] } = await request.json();

  const result = streamText({
    model: 'xai/grok-4.6',
    instructions: 'You are a friendly assistant!',
    messages: await convertToModelMessages(messages),
    stopWhen: isStepCount(5),
  });

  return createUIMessageStreamResponse({
    stream: toUIMessageStream({ stream: result.stream }),
  });
}

The stopWhen: isStepCount(5) deserves attention: it limits the number of steps the model can chain. Without it, an assistant that calls a tool, reads the result, calls another one, and then responds could run in a loop. Five steps is a conservative ceiling to start with.

The tool: a function with a typed schema

The heart of generative UI is the tool. It's a function the model can decide to call, with input validated by Zod. The inputSchema isn't just validation: it's what the model reads to know which arguments it needs to extract from the conversation.

ts
import { tool as createTool } from 'ai';
import { z } from 'zod';

export const weatherTool = createTool({
  description: 'Display the weather for a location',
  inputSchema: z.object({
    location: z.string().describe('The location to get the weather for'),
  }),
  execute: async function ({ location }) {
    await new Promise(resolve => setTimeout(resolve, 2000));
    return { weather: 'Sunny', temperature: 75, location };
  },
});

export const tools = { displayWeather: weatherTool };

That 2-second setTimeout is the source simulating a real API call. Keep that number in mind: it's those 2 seconds of waiting that justify all the care with UI states further ahead. In real life you'd swap it for a call to an actual weather service.

Just inject tools into streamText for the model to start considering the tool:

ts
import { tools } from '@/ai/tools';

const result = streamText({
  model: 'xai/grok-4.6',
  instructions: 'You are a friendly assistant!',
  messages: await convertToModelMessages(messages),
  stopWhen: isStepCount(5),
  tools,
});

The component and the three states you need to handle

The component itself is plain, dumb React, and that's a good thing. It receives exactly what the tool returns:

tsx
type WeatherProps = { temperature: number; weather: string; location: string };

export const Weather = ({ temperature, weather, location }: WeatherProps) => (
  <div>
    <h2>Current Weather for {location}</h2>
    <p>Condition: {weather}</p>
    <p>Temperature: {temperature}°C</p>
  </div>
);

Now the part that separates a pretty demo from a UI that can handle real users. In 5.0, tool parts have typed naming: instead of a generic type, the part comes as tool-${toolName}, that is, tool-displayWeather. And each one carries a state. There are three that matter:

  • input-available: the model has already decided to call the tool and sent the arguments, but execute hasn't finished yet. This is the window of those 2 seconds. Here you show a skeleton or "Loading weather..."
  • output-available: the tool has returned. Render the component with part.output.
  • output-error: something went wrong. Show part.errorText, don't leave the UI blank.
tsx
if (part.type === 'tool-displayWeather') {
  switch (part.state) {
    case 'input-available':
      return <div key={index}>Loading weather...</div>;
    case 'output-available':
      return <div key={index}><Weather {...part.output} /></div>;
    case 'output-error':
      return <div key={index}>Error: {part.errorText}</div>;
    default:
      return null;
  }
}

This is where perceived performance lives. Without handling input-available, the user stares at nothing for two seconds and thinks the app froze. Handling that state, they see the placeholder appear the instant the model decides to call the tool, well before the data arrives. The total time is the same; the feeling of "it's working" starts right at the beginning of the stream instead of only at the end. My advice: never send an output-available without having first drawn the corresponding input-available, with the same box size, so there's no layout shift when the real card replaces the skeleton.

Scaling to multiple components

The pattern repeats without ceremony. A stock-quote tool is just copying and pasting the structure:

ts
export const stockTool = createTool({
  description: 'Get price for a stock',
  inputSchema: z.object({
    symbol: z.string().describe('The stock symbol to get the price for'),
  }),
  execute: async ({ symbol }) => {
    await new Promise(resolve => setTimeout(resolve, 2000));
    return { symbol, price: 100 };
  },
});

export const tools = {
  displayWeather: weatherTool,
  getStockPrice: stockTool,
};

On the client, just another if (part.type === 'tool-getStockPrice') block with the same three-state switch, rendering a . The model now chooses between displayWeather and getStockPrice depending on the question, and the UI adapts on its own. That's the real gain: you don't write the logic for "when to show the stock card"; the model decides, and your job becomes making sure each state has a decent render.

What's left open

A few points that the intro docs don't settle and that I'd raise before shipping this to production. First: accessibility. The example components are plain divs; in a real app I'd announce the card's arrival via aria-live for screen readers, since it appears asynchronously in the stream. Second: the chained ifs by tool type turn into a giant switch too quickly, so it's worth extracting a toolName -> component map early. Third: the example uses xai/grok-4.6, but the same code runs with any supported provider, just by swapping the model string. And finally, stopWhen: five steps is a starting point, not a rule, and it's the parameter you'll calibrate once the assistant starts really chaining tools.

The complete documentation for this flow is at ai-sdk.dev/docs/ai-sdk-ui/generative-user-interfaces.

Translated from the Brazilian Portuguese original · Read the original

Read also