n4nAI

Vercel AI SDK's streamUI: rendering components from GPT-4o

Hands-on tutorial: use Vercel AI SDK streamUI with GPT-4o to render React components from model tool calls. Step-by-step setup, code, and output.

n4n Team2 min read520 words

Audio narration

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

The vercel ai sdk streamui gpt-4o components pattern lets you turn a model’s tool call into a rendered React Server Component without building a custom orchestration layer. In this tutorial we build a minimal Next.js app that streams a weather card component directly from GPT-4o’s response. You’ll see the exact server action, client hooks, and the markup that hits the browser.

Prerequisites

  • Node.js 18.17+ and a package manager (pnpm used here)
  • Next.js 14 with the App Router and TypeScript
  • An OpenAI API key, or any OpenAI-compatible endpoint
  • Working knowledge of React Server Components and server actions

If you route through a gateway, the only change is the base URL. For example, n4n.ai exposes one OpenAI-compatible endpoint that addresses 240+ models; point @ai-sdk/openai at it and the rest of this code is unchanged.

Scaffold the project

pnpm create next-app@latest streamui-demo --ts --app --eslint --src-dir
cd streamui-demo
pnpm add ai@^3.1 @ai-sdk/openai@^0.0.40 zod

The streamUI function lives in ai/rsc, not the main ai entry point. Pin these versions—the RSC API is still experimental and shifts between minors.

Server action with streamUI

Create src/app/actions.tsx. This module runs on the server. We define a tool that returns a component and a text fallback for non-tool replies.

// src/app/actions.tsx
import { streamUI } from 'ai/rsc';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';
import WeatherCard from './components/weather-card';

export async function submitMessage(message: string) {
  const result = await streamUI({
    model: openai('gpt-4o'),
    system: 'You are a weather bot. Use showWeather for any city query.',
    messages: [{ role: 'user', content: message }],
    text: ({ content }) => <p>{content}</p>,
    tools: {
      showWeather: {
        parameters: z.object({ city: z.string() }),
        generate: async function* ({ city }) {
          const data = { city, temp: 21, condition: 'Partly cloudy' };
          return <WeatherCard {...data} />;
        },
      },
    },
  });

  return result;
}

generate can be an async generator that yields intermediate nodes, but for a single component a returned node is enough. The text renderer handles plain model output.

If you use a gateway, configure the provider like this before calling streamUI:

import { createOpenAI } from '@ai-sdk/openai';
const openai = createOpenAI({
  baseURL: 'https://api.n4n.ai/v1',
  apiKey: process.env.N4N_API_KEY,
});

The rendered component

src/app/components/weather-card.tsx is a plain server component:

export default function WeatherCard({ city, temp, condition }: {
  city: string;
  temp: number;
  condition: string;
}) {
  return (
    <div className="card">
      <h3>{city}</h3>
      <p>{temp}°C · {condition}</p>
    </div>
  );
}

No 'use client' directive. It renders on the server and is streamed as an RSC payload.

Client wiring

The client uses useUIState and useActions from ai/rsc. Replace src/app/page.tsx:

'use client';
import { useUIState, useActions } from 'ai/rsc';
import { useState } from 'react';
import { submitMessage } from './actions';

export default function Page() {
  const [input, setInput] = useState('');
  const [messages, setMessages] = useUIState();
  const { submitMessage: action } = useActions();

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    setMessages((prev) => [...prev, { role: 'user', content: input }]);
    const response = await action(input);
    setMessages((prev) => [...prev, { role: 'assistant', content: response.value }]);
    setInput('');
  }

  return (
    <div>
      <form onSubmit={handleSubmit}>
        <input value={input} onChange={(e) => setInput(e.target.value)} />
        <button type="submit">Send</button>
      </form>
      <div>
        {messages.map((m, i) => (
          <div key={i}>{m.content}</div>
        ))}
      </div>
    </div>
  );
}

response.value is the React node streamed from streamUI. The useUIState hook stores these nodes across renders.

Checkpoint: first run

pnpm dev

Open http://localhost:3000. Type: What's the weather in Oslo?

Expected DOM after the stream resolves:

<div class="card">
  <h3>Oslo</h3>
  <p>21°C · Partly cloudy</p>
</div>

In the network tab you’ll see a single POST to the server action returning a fragmented RSC stream. If GPT-4o ignores the tool, you’ll get a <p> with its textual reply instead.

Forcing tool calls

GPT-4o sometimes answers from parametric memory. Force the tool with toolChoice:

const result = await streamUI({
  model: openai('gpt-4o'),
  system: 'You are a weather bot. Use showWeather for any city query.',
  messages: [{ role: 'user', content: message }],
  toolChoice: 'required',
  text: ({ content }) => <p>{content}</p>,
  tools: { /* same as before */ },
});

Now every message returns the component.

Adding a second component

Extend the action with a showStock tool:

import StockCard from './components/stock-card';

tools: {
  showWeather: { /* ... */ },
  showStock: {
    parameters: z.object({ symbol: z.string() }),
    generate: async function* ({ symbol }) {
      return <StockCard symbol={symbol} price={182.33} />;
    },
  },
}

The client code does not change. vercel ai sdk streamui gpt-4o components scales to N tools because the SDK multiplexes the tool selection and streams whichever component the model picks.

Understanding the UI state shape

useUIState holds an array of { role, content }. The content for assistant turns is a React node, not a string. If you inspect it in dev tools you’ll see an object with $$typeof: Symbol(react.element). That’s expected—do not JSON.stringify it.

Production caveats

  • streamUI is experimental in AI SDK 3.x; lock the version in package.json.
  • RSC streams are dynamic. Ensure the route is not statically optimized (export const dynamic = 'force-dynamic' in the page if needed).
  • Secrets stay in generate on the server. The client only receives rendered markup.
  • Provider errors (rate limits, degradation) throw inside the stream. Wrap the action call in try/catch and surface a fallback UI.

Why this beats manual orchestration

Without vercel ai sdk streamui gpt-4o components, you’d parse JSON tool calls, map them to component types, and serialize props yourself. The SDK collapses that into one function and streams React directly. The model output becomes UI without an intermediate representation you have to hand-code, which is the entire reason to use the RSC pipeline.

Tagsvercel-ai-sdkstreamuigpt-4ogenerative-ui

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 →