UI Generation: Letting AI Shape the Interface
What if a user could ask for something in plain English and your app responded with the right interface, not just text? That is the idea behind generative UI. The model does not need to write arbitrary code. It can choose from components you already trust.
By combining LLM tool calls with React components, we can build applications that respond with UI tailored to the user's request while keeping the actual rendering logic under our control.
How Does it Work?
In AI SDK RSC, the main helper for this is streamUI. The model chooses from tools you define. Each tool has a schema and a generate function that returns React UI. The model is not generating arbitrary code. It is deciding which trusted tool to call and with what data.
This approach is useful because:
- You keep control: The AI selects tools and data; your application renders known components.
- It fits a design system: The UI still comes from your components, not from arbitrary generated markup.
- It can stream: The server can show intermediate UI while a tool is running.
One caveat: AI SDK RSC is still experimental. For production work, I would also consider a simpler pattern where the model returns structured data with generateObject or streamObject, then the app maps that data to components.
Building a Weather App Generator
Let's build a simple app that lets a user ask for a weather forecast, and in response, the AI will generate a weather card component.
1. Define the Tool Input with Zod
First, define the data the model is allowed to pass into your tool. The model is not choosing arbitrary props for arbitrary components. It is calling a tool with a validated input shape.
// lib/ui-generation/schemas.ts
import { z } from 'zod';
export const weatherInputSchema = z.object({
city: z.string().describe("The city for the weather forecast."),
});
2. Create the Server Action
Next, we create a server action that will be called from our front end. This is where streamUI lives. It takes the user's prompt, calls the model, and returns streamable UI.
This example will use a mock weather API, but you could easily swap it out for a real one.
// app/actions.tsx
'use server';
import { streamUI } from '@ai-sdk/rsc';
import { openai } from '@ai-sdk/openai';
import { WeatherCard } from '@/components/WeatherCard';
import { Spinner } from '@/components/Spinner';
import { weatherInputSchema } from '@/lib/ui-generation/schemas';
// Mock function to simulate fetching weather data
async function getWeatherData(city: string) {
// In a real app, you'd call a real weather API here
console.log(`Fetching weather for ${city}...`);
return {
temperature: Math.floor(Math.random() * 30),
condition: ['Sunny', 'Cloudy', 'Rainy', 'Stormy', 'Windy'][Math.floor(Math.random() * 5)],
humidity: Math.floor(Math.random() * 100),
};
}
export async function getWeather(prompt: string) {
const result = await streamUI({
model: openai('gpt-4o'),
system: "You are a helpful weather assistant. Use the user's prompt to determine the city for the weather forecast. Use the `getWeather` tool to fetch the data, and then display it using the `weather` component.",
prompt,
text: ({ content }) => <p>{content}</p>,
tools: {
getWeather: {
description: 'Get the weather for a city.',
inputSchema: weatherInputSchema,
generate: async function* ({ city }) {
// Show a spinner while we fetch the data
yield <Spinner message={`Getting the forecast for ${city}...`} />;
const weatherData = await getWeatherData(city);
// Return the final weather card
return <WeatherCard {...weatherData} city={city} />;
},
},
},
});
return result.value;
}
Note: This example uses @ai-sdk/rsc, which is designed for React Server Components and is still experimental. If you need the most stable production path, generate structured data and render components yourself.
3. Build the Front End
Finally, we create the front-end components. We need the WeatherCard and Spinner that the AI will render, and a simple form to take the user's input.
// components/WeatherCard.tsx
export function WeatherCard({ city, temperature, condition, humidity }) {
return (
<div className="p-4 bg-blue-100 border border-blue-300 rounded-lg">
<h3 className="text-xl font-bold">{city}</h3>
<p className="text-4xl">{temperature}°C</p>
<p>Condition: {condition}</p>
<p>Humidity: {humidity}%</p>
</div>
);
}
// components/Spinner.tsx
export function Spinner({ message }) {
return (
<div className="p-4 bg-gray-100 border border-gray-300 rounded-lg">
<p>{message}</p>
</div>
);
}
And the main chat interface:
// app/page.tsx
'use client';
import { useState } from 'react';
import { getWeather } from './actions';
export default function Page() {
const [prompt, setPrompt] = useState('');
const [ui, setUi] = useState(null);
const handleSubmit = async (e) => {
e.preventDefault();
const newUi = await getWeather(prompt);
setUi(newUi);
};
return (
<div className="p-4">
<form onSubmit={handleSubmit} className="flex gap-2">
<input
type="text"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="e.g., What's the weather in London?"
className="flex-1 p-2 border rounded-lg"
/>
<button type="submit" className="px-4 py-2 bg-blue-600 text-white rounded-lg">
Ask
</button>
</form>
<div className="mt-4">{ui}</div>
</div>
);
}
Now, when a user types a city and clicks "Ask", the server action can stream the loading UI, run the weather tool, and return the final WeatherCard.
Generative UI works best when the model chooses between trusted tools and your application stays in charge of rendering. Keep the component set small, validate every input, and treat the model as an orchestration layer rather than a code generator.