n4nAI

Generative UI weather widget with Vercel AI SDK

Build a generative UI weather widget with Vercel AI SDK using React Server Components, streaming tool calls, and server-side rendering for real-time weather data.

n4n Team3 min read608 words

Audio narration

Coming soon — every post will get a voice note here.

The vercel ai sdk generative ui weather widget pattern lets you stream React components directly from the server as the model decides what to render. Instead of returning JSON and mapping it to components on the client, the model emits JSX that hydrates in real time. This tutorial builds a weather widget where the assistant calls a weather tool, then streams a typed WeatherCard component with live data — no client-side fetching, no manual state plumbing.

Prerequisites

  • Node.js 20+ with pnpm (or npm/yarn)
  • A Vercel account for deployment, or Docker for local hosting
  • An OpenWeatherMap API key (free tier works)
  • Basic familiarity with Next.js 14+ App Router and React Server Components

Create the project:

pnpm create next-app@latest weather-widget --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd weather-widget
pnpm add ai @ai-sdk/openai zod
pnpm add -D @types/node

Project structure

src/
├── app/
│   ├── api/chat/route.ts       # Server action streaming generative UI
│   ├── page.tsx                # Client entry point
│   └── layout.tsx
├── components/
│   ├── WeatherCard.tsx         # The generative UI component
│   └── ChatInterface.tsx       # Client-side chat wrapper
├── lib/
│   ├── tools.ts                # Weather tool definition
│   └── weather.ts              # OpenWeatherMap client
└── types.ts                    # Shared Zod schemas

Define the weather tool

The tool returns structured data the model can pass to the WeatherCard component. Use Zod for validation — the AI SDK uses it for both tool parameters and the component props.

// src/lib/tools.ts
import { tool } from 'ai';
import { z } from 'zod';

export const weatherTool = tool({
  parameters: z.object({
    latitude: z.number().min(-90).max(90),
    longitude: z.number().min(-180).max(180),
    unit: z.enum(['celsius', 'fahrenheit']).default('celsius'),
  }),
  execute: async ({ latitude, longitude, unit }) => {
    const response = await fetch(
      `https://api.openweathermap.org/data/2.5/weather?lat=${latitude}&lon=${longitude}&units=${unit === 'celsius' ? 'metric' : 'imperial'}&appid=${process.env.OPENWEATHER_API_KEY}`
    );
    if (!response.ok) throw new Error('Weather API failed');
    const data = await response.json();
    return {
      location: data.name,
      country: data.sys.country,
      temperature: Math.round(data.main.temp),
      feelsLike: Math.round(data.main.feels_like),
      humidity: data.main.humidity,
      windSpeed: Math.round(data.wind.speed * 3.6), // m/s to km/h
      condition: data.weather[0].main,
      icon: data.weather[0].icon,
      timestamp: Date.now(),
    };
  },
});
// src/types.ts
import { z } from 'zod';

export const WeatherDataSchema = z.object({
  location: z.string(),
  country: z.string(),
  temperature: z.number(),
  feelsLike: z.number(),
  humidity: z.number(),
  windSpeed: z.number(),
  condition: z.string(),
  icon: z.string(),
  timestamp: z.number(),
});

export type WeatherData = z.infer<typeof WeatherDataSchema>;

Build the generative UI component

This is the component the model will stream. It receives WeatherData as props and renders a self-contained card. Mark it 'use client' because it uses useId for hydration keys, but the initial render comes from the server.

// src/components/WeatherCard.tsx
'use client';

import { WeatherData } from '@/types';

interface WeatherCardProps {
  data: WeatherData;
}

export function WeatherCard({ data }: WeatherCardProps) {
  const iconUrl = `https://openweathermap.org/img/wn/${data.icon}@2x.png`;
  const unit = '°C'; // matches default in tool

  return (
    <div className="rounded-xl border bg-card p-4 shadow-sm w-full max-w-sm">
      <div className="flex items-start justify-between gap-4">
        <div>
          <h3 className="text-lg font-semibold">{data.location}, {data.country}</h3>
          <p className="text-sm text-muted-foreground capitalize">{data.description}</p>
        </div>
        <img src={iconUrl} alt={data.condition} width={64} height={64} className="shrink-0" />
      </div>

      <div className="mt-4 grid grid-cols-3 gap-4 text-center">
        <div>
          <div className="text-3xl font-bold tabular-nums">{data.temperature}{unit}</div>
          <div className="text-xs text-muted-foreground">Feels like {data.feelsLike}{unit}</div>
        </div>
        <div className="border-l border-r px-2">
          <div className="text-lg font-semibold tabular-nums">{data.humidity}%</div>
          <div className="text-xs text-muted-foreground">Humidity</div>
        </div>
        <div>
          <div className="text-lg font-semibold tabular-nums">{data.windSpeed} km/h</div>
          <div className="text-xs text-muted-foreground">Wind</div>
        </div>
      </div>

      <p className="mt-3 text-xs text-muted-foreground text-center">
        Updated {new Date(data.timestamp).toLocaleTimeString()}
      </p>
    </div>
  );
}

Create the chat route with generative UI

The route uses streamUI from ai — this is the core of the vercel ai sdk generative ui weather widget pattern. The model calls weatherTool, then renders WeatherCard with the result. The stream sends React Server Component payloads the client hydrates.

// src/app/api/chat/route.ts
import { streamUI } from 'ai';
import { openai } from '@ai-sdk/openai';
import { weatherTool } from '@/lib/tools';
import { WeatherCard } from '@/components/WeatherCard';

export const maxDuration = 30;

export async function POST(req: Request) {
  const { messages } = await req.json();

  const result = streamUI({
    model: openai('gpt-4o'),
    system: `You are a weather assistant. When users ask for weather, use the weather tool.
    The tool requires latitude and longitude. If the user provides a city name, ask for coordinates
    or use a geocoding service. For this demo, ask the user to provide lat/long.`,
    messages,
    tools: {
      weather: weatherTool,
    },
    components: {
      WeatherCard,
    },
  });

  return result.toUIStreamResponse();
}

Wire the client chat interface

The client uses useChat from @ai-sdk/react with the experimental_useUIState hook to render streamed components. This is where the generative UI appears.

// src/components/ChatInterface.tsx
'use client';

import { useChat } from '@ai-sdk/react';
import { useState, FormEvent } from 'react';
import { WeatherCard } from './WeatherCard';

export function ChatInterface() {
  const [input, setInput] = useState('');
  const { messages, append, status, ui } = useChat({
    api: '/api/chat',
    experimental_useUIState: true,
  });

  const handleSubmit = (e: FormEvent) => {
    e.preventDefault();
    if (!input.trim()) return;
    append({ role: 'user', content: input });
    setInput('');
  };

  return (
    <div className="flex flex-col h-[calc(100vh-4rem)] w-full max-w-2xl mx-auto p-4">
      <div className="flex-1 overflow-y-auto space-y-4">
        {messages.map((message, i) => (
          <div key={i} className={`flex gap-3 ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}>
            <div
              className={`max-w-[80%] rounded-2xl px-4 py-2 ${
                message.role === 'user'
                  ? 'bg-primary text-primary-foreground rounded-br-none'
                  : 'bg-muted rounded-bl-none'
              }`}
            >
              {typeof message.content === 'string' ? (
                <p className="whitespace-pre-wrap">{message.content}</p>
              ) : (
                message.content.map((component: React.ReactElement, idx: number) => (
                  <div key={idx}>{component}</div>
                ))
              )}
            </div>
          </div>
        ))}

        {/* Streamed UI components appear here */}
        {ui.map((component: React.ReactElement, idx: number) => (
          <div key={idx} className="flex justify-start">
            <div className="max-w-[80%] bg-muted rounded-2xl rounded-bl-none p-2">
              {component}
            </div>
          </div>
        ))}

        {status === 'streaming' && (
          <div className="flex justify-start">
            <div className="bg-muted rounded-2xl rounded-bl-none p-2 animate-pulse">
              <div className="h-4 w-3/4 bg-muted-foreground/20 rounded" />
              <div className="mt-2 h-4 w-1/2 bg-muted-foreground/20 rounded" />
            </div>
          </div>
        )}
      </div>

      <form onSubmit={handleSubmit} className="mt-4 flex gap-2">
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="Ask for weather (provide lat/long)..."
          className="flex-1 rounded-lg border bg-background px-4 py-2 focus:outline-none focus:ring-2 focus:ring-ring"
          disabled={status === 'submitting' || status === 'streaming'}
        />
        <button
          type="submit"
          disabled={!input.trim() || status === 'submitting' || status === 'streaming'}
          className="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
        >
          Send
        </button>
      </form>
    </div>
  );
}

Update the main page

// src/app/page.tsx
import { ChatInterface } from '@/components/ChatInterface';

export default function Home() {
  return (
    <main className="min-h-screen bg-background">
      <header className="border-b px-4 py-4">
        <h1 className="text-2xl font-bold text-center">Weather Widget</h1>
        <p className="text-center text-sm text-muted-foreground mt-1">
          Ask for weather with latitude/longitude coordinates
        </p>
      </header>
      <ChatInterface />
    </main>
  );
}

Add environment variables

# .env.local
OPENWEATHER_API_KEY=your_key_here
OPENAI_API_KEY=your_openai_key_here

Run and verify

pnpm dev

Open http://localhost:3000. Try: “What’s the weather at latitude 40.7128, longitude -74.0060?”

Expected stream sequence:

  1. User message appears instantly
  2. Assistant thinking indicator pulses
  3. Tool call executes server-side (you see network request to OpenWeatherMap in DevTools)
  4. WeatherCard component streams and hydrates — you see the card populate field by field as the RSC payload arrives
  5. Final card is interactive (no, it’s static, but it hydrated from server)

How the streaming works

When streamUI runs:

  1. Model receives messages + tool definitions
  2. Model emits a tool call: weather({ latitude: 40.7128, longitude: -74.0060 })
  3. Server executes the tool, gets WeatherData
  4. Model emits a component render: <WeatherCard data={...} />
  5. The RSC payload streams to client via toUIStreamResponse()
  6. Client useChat with experimental_useUIState receives the component tree and renders it

The key insight: the model decides which component to render and with what props. You don’t write if (toolResult) return <WeatherCard /> — the model does.

Handling coordinates in practice

Asking users for lat/long is friction. Add a geocoding tool:

// src/lib/tools.ts (add to existing)
export const geocodeTool = tool({
  parameters: z.object({
    city: z.string(),
  }),
  execute: async ({ city }) => {
    const response = await fetch(
      `https://api.openweathermap.org/geo/1.0/direct?q=${encodeURIComponent(city)}&limit=1&appid=${process.env.OPENWEATHER_API_KEY}`
    );
    const data = await response.json();
    if (!data.length) throw new Error('City not found');
    return { latitude: data[0].lat, longitude: data[0].lon, name: data[0].name };
  },
});

Update the route to include geocode in tools and adjust the system prompt:

system: `You are a weather assistant. When users ask for weather by city name,
use the geocode tool first, then the weather tool with the returned coordinates.`,
tools: {
  weather: weatherTool,
  geocode: geocodeTool,
},

Now users can say “Weather in Tokyo” and the model chains the tools automatically.

Error handling and loading states

The WeatherCard renders instantly with data, but what if the tool fails? Wrap the tool execute in a try/catch and return a structured error the model can render as an error component.

// src/components/ErrorCard.tsx
'use client';

interface ErrorCardProps {
  message: string;
}

export function ErrorCard({ message }: ErrorCardProps) {
  return (
    <div className="rounded-xl border border-destructive bg-destructive/10 p-4 text-destructive w-full max-w-sm">
      <p className="font-medium">Unable to fetch weather</p>
      <p className="text-sm mt-1">{message}</p>
    </div>
  );
}

Register it in the route:

components: {
  WeatherCard,
  ErrorCard,
},

The model can now emit <ErrorCard message="Rate limited" /> on failure.

Deployment notes

Deploy to Vercel with zero config — the edge runtime handles streaming natively. For Docker:

# Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]

Set OPENWEATHER_API_KEY and OPENAI_API_KEY in your platform’s environment variables.

What you’ve built

A vercel ai sdk generative ui weather widget that:

  • Streams React components from server to client as the model reasons
  • Executes tools server-side (no client API keys exposed)
  • Hydrates interactive components without client-side data fetching
  • Chains multiple tools (geocode → weather) automatically
  • Handles errors as first-class UI components

The pattern extends to any domain: product cards, charts, maps, forms — anything the model can select and populate with tool data. The server owns the logic; the model owns the presentation.

Tagsvercel-ai-sdkgenerative-uiweather-widgettutorial

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All vercel ai sdk generative ui with react server components posts →