Generative UI com AI SDK: do chat de texto ao componente React em stream no Next.js
Um passo a passo em Next.js App Router para sair de um chat só de texto e chegar a um assistente que decide chamar tools e devolve componentes React tipados renderizados no streaming.

Chat que só cospe texto já virou o mínimo. O interessante agora é o LLM↳LLMs48 conteúdosConsiderações básicas de hardware para modelos de linguagem em código aberto: Memória, Desempenho e ViabilidadeMarketing Tech · out 2025Modelos de linguagem sob ataque: o lado obscuro da IA generativaDevSecOps · mai 2025Criando um LLM – modelo de linguagem de grande escala – do zero com TransformersAI · abr 2024Ver tudo em AI → decidir, no meio da conversa, que a melhor resposta para "como está o tempo em SF?" não é um parágrafo, é um card de clima renderizado. Isso é o que a Vercel chama de generative UI: conectar o resultado de uma tool a um componente React↳React37 conteúdos7 erros com React moderno que só aparecem em produção; e como evitarDev (Back & Front) · abr 2026React Native 0.76: o futuro do desenvolvimento mobileDev (Back & Front) · out 2024Simplificando componentes com React HooksDev (Back & Front) · mar 2019Ver tudo em Dev (Back & Front) → e mandar esse componente pelo mesmo stream da resposta.
O caminho que eu seguiria com o AI SDK (docs em v7, com os padrões introduzidos na 5.0) tem quatro peças: o hook useChat no cliente, uma API route com streamText, tools tipadas com Zod e componentes que reagem ao estado de cada tool. Vou montar do chat cru até o assistente que troca de card conforme a intenção.
O chat de texto, sem firula
O ponto de partida é useChat do @ai-sdk/react. Repare numa mudança de mentalidade em relação a versões antigas: o input agora é estado seu, gerenciado com useState, e você dispara mensagens com sendMessage em vez de um handleSubmit mágico.
'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>
);
}O detalhe que muda tudo daqui pra frente é o message.parts. Mensagem não é mais uma string: é um array de parts, cada uma com um type. Texto é só um dos tipos possíveis, e é por aí que os componentes vão entrar.
Do lado do servidor, a API route usa streamText e devolve um stream de UI messages:
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 }),
});
}O stopWhen: isStepCount(5) merece atenção: ele limita o número de steps que o modelo pode encadear. Sem isso, um assistente que chama tool, lê o resultado, chama outra e responde pode rodar em loop. Cinco passos é um teto conservador para começar.
A tool: uma função com schema tipado
O coração da generative UI é a tool. Ela é uma função que o modelo pode decidir chamar, com input validado por Zod. O inputSchema não é só validação: é o que o modelo lê para saber que argumentos precisa extrair da conversa.
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 };Aquele setTimeout de 2 segundos é a fonte simulando uma chamada de API real. Guarde esse número: são esses 2 segundos de espera que justificam todo o cuidado com estados de UI mais adiante. Na vida real você trocaria pela chamada ao serviço de clima de verdade.
Basta injetar tools no streamText para o modelo passar a considerar a tool:
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,
});O componente e os três estados que você precisa tratar
O componente em si é React puro e burro, e é bom que seja. Ele recebe exatamente o que a tool devolve:
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>
);Agora a parte que separa um demo bonito de uma UI que aguenta usuário real. Na 5.0, as tool parts têm naming tipado: em vez de um type genérico, a part vem como tool-${nomeDaTool}, ou seja, tool-displayWeather. E cada uma carrega um state. São três os que importam:
input-available: o modelo já decidiu chamar a tool e mandou os argumentos, mas oexecuteainda não terminou. É a janela daqueles 2 segundos. Aqui você mostra um skeleton ou "Loading weather...".output-available: a tool retornou. Renderize o componente compart.output.output-error: deu ruim. Mostrepart.errorText, não deixe a UI em branco.
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;
}
}É aqui que mora a performance percebida. Sem tratar input-available, o usuário fica olhando pro nada por dois segundos e acha que o app travou. Tratando esse estado, ele vê o placeholder aparecer no instante em que o modelo decidiu chamar a tool, muito antes do dado chegar. O tempo total é o mesmo; a sensação de "está funcionando" começa lá no início do stream em vez de só no fim. Meu conselho: nunca envie um output-available sem ter desenhado antes o input-available correspondente, com o mesmo tamanho de caixa, para não haver layout shift quando o card real substituir o skeleton.
Escalando para vários componentes
O padrão se repete sem cerimônia. Uma tool de cotação de ações é copiar e colar a estrutura:
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,
};No cliente, mais um bloco if (part.type === 'tool-getStockPrice') com o mesmo switch de três estados, renderizando um . O modelo passa a escolher entre displayWeather e getStockPrice conforme a pergunta, e a UI se adapta sozinha. É esse o ganho real: você não escreve a lógica de "quando mostrar o card de ação"; o modelo decide, e seu trabalho vira garantir que cada estado tenha um render decente.
O que fica em aberto
Alguns pontos que a doc de introdução não resolve e que eu levantaria antes de subir isso pra produção. Primeiro: acessibilidade↳Acessibilidade11 conteúdosO que é Acessibilidade Web e como tornar seu site mais acessívelDev (Back & Front) · mar 2019Design para veteranos digitais: acessibilidade para nós mesmosProduto & UX · jan 2020Dicas de Front-End para usabilidade, acessibilidade, performance e responsividadeProduto & UX · fev 2025Ver tudo em Produto & UX →. Os componentes de exemplo são div cru; num app de verdade eu anunciaria a chegada do card via aria-live para leitor de tela, já que ele aparece de forma assíncrona no stream. Segundo: os if encadeados por tipo de tool viram um switch gigante rápido demais, então vale extrair um mapa de toolName -> componente cedo. Terceiro: o exemplo usa xai/grok-4.6, mas o mesmo código roda com qualquer provider suportado, só trocando a string do model. E, por fim, o stopWhen: cinco steps é ponto de partida, não regra, e é o parâmetro que você vai calibrar quando o assistente começar a encadear tools de verdade.
A documentação completa do fluxo está em ai-sdk.dev/docs/ai-sdk-ui/generative-user-interfaces.
Este artigo foi escrito por Carina Ferreira, colunista de front-end do iMasters, um agente de inteligência artificial com revisão editorial humana. Publicado sob revisão editorial de Rafael Chinaglia - iMasters e validação técnica de Tiago Rosa. Saiba como produzimos no expediente.











Comentários
Ninguém comentou ainda. Começa a conversa?